From 2af85228c0fbec71cbbdcddffbf89597451466d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:15:00 -0700 Subject: [PATCH 01/18] feat(credentials): add v2 OAuth connection APIs --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 353 +++++++++++++++++- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 2 +- .../api/auth/oauth2/authorize/route.test.ts | 85 ++++- .../app/api/auth/oauth2/authorize/route.ts | 180 +++++---- .../v2/credential-connections/route.test.ts | 150 ++++++++ .../api/v2/credential-connections/route.ts | 32 ++ .../api/v2/credential-providers/route.test.ts | 124 ++++++ .../app/api/v2/credential-providers/route.ts | 27 ++ .../oauth/credential-connected/page.test.tsx | 36 ++ .../app/oauth/credential-connected/page.tsx | 50 +++ .../lib/api/contracts/oauth-connections.ts | 46 ++- .../v2/__tests__/list-pagination.test.ts | 1 + apps/sim/lib/api/contracts/v2/credentials.ts | 129 +++++++ .../lib/api/contracts/v2/openapi/resources.ts | 77 +++- apps/sim/lib/core/application/forbidden.ts | 4 + .../application/connection-target.test.ts | 150 ++++++++ .../application/connection-target.ts | 92 +++++ .../create-credential-connection.test.ts | 128 +++++++ .../create-credential-connection.ts | 53 +++ .../launch-credential-connection.test.ts | 93 +++++ .../launch-credential-connection.ts | 52 +++ .../list-credential-providers.test.ts | 68 ++++ .../application/list-credential-providers.ts | 29 ++ .../lib/credentials/application/operations.ts | 18 + .../application/provider-catalog.test.ts | 176 +++++++++ .../application/provider-catalog.ts | 113 ++++++ apps/sim/lib/credentials/connect-draft.ts | 83 ++-- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 34 files changed, 2244 insertions(+), 125 deletions(-) create mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-connections/route.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credential-providers/route.ts create mode 100644 apps/sim/app/oauth/credential-connected/page.test.tsx create mode 100644 apps/sim/app/oauth/credential-connected/page.tsx create mode 100644 apps/sim/lib/credentials/application/connection-target.test.ts create mode 100644 apps/sim/lib/credentials/application/connection-target.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/create-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/launch-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.test.ts create mode 100644 apps/sim/lib/credentials/application/list-credential-providers.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.test.ts create mode 100644 apps/sim/lib/credentials/application/provider-catalog.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 5b513c1c03d..eada35ce0ed 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -477,7 +477,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 3e605aa00e1..6a29b4542a5 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2245,7 +2245,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e0d93148561..d7e63995376 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2206,7 +2206,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index eaf97c33c96..89b62afab4c 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -596,7 +596,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 6b7fe8bd62b..69ae6f3206e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "List OAuth and service-account connections without secret material." + "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." }, { "name": "Secrets", @@ -1705,6 +1705,140 @@ } } }, + "/api/v2/credential-providers": { + "get": { + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace used to evaluate OAuth availability and integration policy.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace used to evaluate OAuth availability and integration policy." + } + } + ], + "responses": { + "200": { + "description": "OAuth provider catalog with caller-specific availability.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListCredentialProvidersResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/credential-connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionBody" + } + } + } + }, + "responses": { + "200": { + "description": "A short-lived browser authorization URL.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -2264,7 +2398,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -4075,6 +4209,221 @@ } ] }, + "V2CredentialProvider": { + "type": "object", + "properties": { + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable OAuth service identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "OAuth service display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "OAuth service description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can start the OAuth flow in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false, + "title": "Credential Provider", + "description": "An OAuth service that may be connected to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credential providers response", + "description": "OAuth providers and their authorization-server options.", + "examples": [ + { + "data": [ + { + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + } + ], + "nextCursor": null + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact provider ID returned by the credential-provider catalog." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } + }, + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index a4aff379376..c33fe610f69 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3976,7 +3976,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index c93a2b3352e..19a0b5f6fcd 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2292,7 +2292,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 54f49a5e29f..938e0f3310a 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -4,8 +4,10 @@ import { createMockRequest, dbChainMockFns, + queueTableRows, resetDbChainMock, resetEnvMock, + schemaMock, setEnv, } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,11 +17,13 @@ const { mockOAuth2LinkAccount, mockCheckWorkspaceAccess, mockGetCredentialActorContext, + mockLaunchCredentialConnection, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockOAuth2LinkAccount: vi.fn(), mockCheckWorkspaceAccess: vi.fn(), mockGetCredentialActorContext: vi.fn(), + mockLaunchCredentialConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ @@ -35,6 +39,13 @@ vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, })) +vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ + launchCredentialConnection: { + operation: { id: 'credentials.connections.launch' }, + execute: mockLaunchCredentialConnection, + }, +})) + vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), // Real implementation: a credential id matches its service's OAuth id, an @@ -99,7 +110,16 @@ describe('OAuth2 authorize route', () => { GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret', }) - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) + mockGetSession.mockResolvedValue({ + user: { id: USER_ID }, + session: { id: 'session-1' }, + }) + queueTableRows(schemaMock.user, [{ name: 'Test User' }]) + dbChainMockFns.onConflictDoUpdate.mockImplementation(() => ({ + returning: vi + .fn() + .mockResolvedValue([{ id: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z') }]), + })) mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -114,6 +134,69 @@ describe('OAuth2 authorize route', () => { }) }) + describe('draft-bound connection', () => { + it('resolves the exact user-bound draft before starting OAuth', async () => { + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: "Test User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + const request = authorizeRequest({ draftId: 'draft-1' }) + + const response = await GET(request) + + expect(response.headers.get('location')).toBe(LINK_URL) + expect(mockLaunchCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: USER_ID, sessionId: 'session-1' }, + input: { draftId: 'draft-1' }, + request, + }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + providerId: 'google-email', + callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected`, + errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, + }, + }) + ) + }) + + it('hands custom providers to their authenticated browser flow', async () => { + setEnv({ TRELLO_API_KEY: 'trello-key' }) + mockLaunchCredentialConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + userId: USER_ID, + workspaceId: WORKSPACE_ID, + providerId: 'trello', + displayName: "Test User's Trello", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), + }, + }) + + const response = await GET(authorizeRequest({ draftId: 'draft-1' })) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected` + ) + expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + }) + }) + describe('plain connect (no credentialId)', () => { it('creates a draft with credentialId null and redirects to the provider', async () => { const response = await GET( diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..49d8d8c7fcf 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -4,9 +4,11 @@ import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -30,92 +32,136 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query + const { draftId } = parsed.data.query + let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query - const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` - - try { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, + let fromConnectionDraft = false + if (draftId) { + try { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const { draft } = await launchCredentialConnection.execute({ + principal: { + kind: 'session', + userId, + sessionId, + }, + input: { draftId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) } + } + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } + + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + try { let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { + if (!fromConnectionDraft) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canWrite) { + logger.warn('Workspace write access denied for OAuth2 authorize', { userId, workspaceId, providerId, - credentialId, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) } - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, + if (credentialId) { + // Trello and Shopify authorize through their own custom flows that bypass + // this endpoint, so a reconnect draft written here would linger unconsumed + // and could later be picked up by their token-store callbacks, silently + // rebinding the credential. Mirror the copilot tool and reject reconnect. + if (providerId === 'trello' || providerId === 'shopify') { + logger.warn('Reconnect not supported for custom-flow provider', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect( + `${baseUrl}/workspace?error=credential_reconnect_unsupported` + ) + } + + // Reconnect: the OAuth callback will rebind this credential to the fresh + // account, so require the same credential-admin access as the draft POST + // route — workspace write alone must not be enough to swap someone's tokens. + const actor = await getCredentialActorContext(credentialId, userId, { + workspaceAccess: access, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + if ( + !actor.credential || + actor.credential.workspaceId !== workspaceId || + actor.credential.type !== 'oauth' || + !actor.isAdmin + ) { + logger.warn('Credential admin access denied for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (actor.credential.providerId !== providerId) { + logger.warn('Provider mismatch for OAuth2 reconnect', { + userId, + workspaceId, + providerId, + credentialId, + credentialProviderId: actor.credential.providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + reconnectDisplayName = actor.credential.displayName } - reconnectDisplayName = actor.credential.displayName } requireConfiguredOAuthClient(providerId) - // Create the draft before initiating the link so it is guaranteed to exist - // (and freshly clocked) when the OAuth callback's `account.create.after` - // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + if (!draftId) { + await createConnectDraft({ + userId, + workspaceId, + providerId, + credentialId, + displayName: reconnectDisplayName, + }) + } + + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + return NextResponse.redirect(authorizeUrl) + } const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL, + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts new file mode 100644 index 00000000000..569eb61dd72 --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credential-connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credential-connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('rejects requests that provide both connection targets', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('requires a display name for a new connection', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('rejects a display name when reconnecting an existing credential', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('returns the short-lived browser URL', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + }) + + it('conceals inaccessible workspaces as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credential-connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credential-connections/route.ts @@ -0,0 +1,32 @@ +import { v2CreateCredentialConnectionContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialConnectionErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialConnectionContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createConnection, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: createCredentialConnection, + present: ({ authorizationUrl, expiresAt }) => ({ + data: { + authorizationUrl, + expiresAt: expiresAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts new file mode 100644 index 00000000000..27de3904a9a --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credential-providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +describe('GET /api/v2/credential-providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + providers: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + }) + }) + + it('returns the full provider catalog in one page', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + expect(await response.json()).toEqual({ + data: [ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + ], + nextCursor: null, + }) + }) + + it('rejects query parameters it does not implement', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('conceals a workspace-key scope mismatch as not found', async () => { + mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credential-providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credential-providers/route.ts @@ -0,0 +1,27 @@ +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialProviderErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialProvidersContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.listProviders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialProviderErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCredentialProviders, + present: ({ providers }) => ({ data: providers, nextCursor: null }), +}) diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..c0583aede98 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,50 @@ +import { ChipLink } from '@sim/emcn' +import { CircleAlert, CircleCheck } from '@sim/emcn/icons' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+
+ {connected ? ( + + ) : ( + + )} +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index c9c4951efd2..3e184df791c 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -262,12 +262,46 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + .optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index b6034e3af3b..4ce9499de71 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -83,6 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credential-providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..60d537b2754 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -4,6 +4,7 @@ import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, @@ -44,6 +45,43 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output +export const v2CredentialProviderAuthorizationOptionSchema = z.object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), +}) +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderSchema = z + .object({ + serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), + name: z.string().min(1).max(255).describe('OAuth service display name.'), + description: z.string().min(1).max(1000).describe('OAuth service description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can start the OAuth flow in the current deployment.'), + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth service that may be connected to a workspace.', + }) +export type V2CredentialProvider = z.output + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -80,3 +118,94 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate OAuth availability and integration policy.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credential-providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact provider ID returned by the credential-provider catalog.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credential-connections', + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..377a1cb7442 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,8 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +170,24 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -804,6 +826,56 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'OAuth provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace used to evaluate provider availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth providers and their authorization-server options.', + [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + resourceOperation('Credentials', { + operationId: 'createCredentialConnection', + summary: 'Create Credential Connection', + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1025,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..8d9ed60e7c5 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can write in the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..6365988099e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,92 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, + requireAvailableCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: CredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string +}): Promise { + const { principal, context, providerId, credentialId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find((entry) => + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..b934755f0e5 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns only its browser entrypoint', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..513e2b21c85 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,53 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName: string; credentialId?: never } + | { credentialId: string; providerId?: never; displayName?: never } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + expiresAt: Date +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + }) + const displayName = input.providerId ? input.displayName : target.displayName + if (!displayName) throw new Error('Resolved credential connection target has no display name') + + const draft = await createConnectDraft({ + userId: principal.userId, + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + expiresAt: draft.expiresAt, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..c7d11d848d9 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('rejects unsupported principals before canonical workspace loading', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..b24d45ea811 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,29 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => ({ + providers: await listCredentialProviderCatalog(principal, context), + }), +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 4a3dcde7c11..7432c1c7430 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,10 +1,28 @@ import { defineWorkspaceOperation } from '@/lib/core/application' export const credentialOperations = { + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', principalKinds: ['personal_api_key', 'workspace_api_key'], }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), } as const diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..51aece09f81 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'service-account-only', + providerId: 'service-account-only', + name: 'Service account', + description: 'Not OAuth.', + baseProvider: 'test', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: false, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..760cda016d0 --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,113 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderCatalogEntry { + serviceId: string + name: string + description: string + providerFamily: string + available: boolean + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices, + }) + + return oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: !['trello', 'shopify'].includes(service.providerId), + authorizationOptions, + } + }) +} + +export function requireAvailableCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): CredentialProviderCatalogEntry { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..0c455f68dff 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,13 +2,20 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') const DRAFT_TTL_MS = 15 * 60 * 1000 +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} + /** * Creates the pending credential draft at OAuth click time so custom and * generic OAuth callbacks can materialize the connected workspace credential. @@ -21,7 +28,7 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,42 +39,21 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } @@ -79,10 +65,11 @@ export async function createConnectDraft(params: { .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, @@ -100,8 +87,11 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) throw new Error('OAuth connect draft insert returned no row') logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +99,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 58393603987..5a40d039ee0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1105, - zodRoutes: 1105, + totalRoutes: 1107, + zodRoutes: 1107, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..191fb41000a 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 22], + ['apps/docs/openapi-v2-resources.json', 24], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(135) + expect(totalOperations).toBe(137) }) it('documents mixed workflow execution and resume responses', () => { From f0767d3af14cc824c8cd3b9c7db500ccb6d75083 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:28:29 -0700 Subject: [PATCH 02/18] fix(credentials): preserve active OAuth connection links --- .../sim/lib/credentials/connect-draft.test.ts | 46 +++++++++++++++++++ apps/sim/lib/credentials/connect-draft.ts | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/credentials/connect-draft.test.ts diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..a230dec4cc8 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('preserves the active draft ID when refreshing the same connection intent', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + expect(conflict?.set).toMatchObject({ + displayName: 'Work Gmail', + credentialId: null, + }) + expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 0c455f68dff..027f6ae26a2 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -87,7 +87,7 @@ export async function createConnectDraft(params: { // credentialId must be written on BOTH paths: a plain connect that reuses a // stale reconnect draft row would otherwise silently rebind the old // credential instead of creating a new one. - set: { id, displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) From e4b09dca7f047a76ede2f84719d5bbeefd6b6361 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 11:37:24 -0700 Subject: [PATCH 03/18] fix(credentials): bind OAuth links to connection intent --- .../api/auth/oauth2/authorize/route.test.ts | 17 +++++------ .../v2/credential-connections/route.test.ts | 30 +++++++++++++++++++ .../sim/lib/credentials/connect-draft.test.ts | 28 +++++++++++++---- apps/sim/lib/credentials/connect-draft.ts | 21 +++++++++---- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 938e0f3310a..c9bb3e8e9dd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -214,9 +214,7 @@ describe('OAuth2 authorize route', () => { }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) @@ -232,11 +230,12 @@ describe('OAuth2 authorize route', () => { ) }) - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { + it('does not overwrite a reconnect intent when refreshing a plain connect', async () => { await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) + const [{ set, setWhere }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] + expect(set).not.toHaveProperty('credentialId') + expect(setWhere).toBeDefined() }) it('rejects an OAuth client that is not configured for the deployment', async () => { @@ -283,7 +282,7 @@ describe('OAuth2 authorize route', () => { }) describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { + it('creates a reconnect draft and guards conflict refreshes by intent', async () => { mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) const response = await GET( @@ -304,9 +303,7 @@ describe('OAuth2 authorize route', () => { expect.objectContaining({ credentialId: CREDENTIAL_ID }) ) expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) + expect.objectContaining({ setWhere: expect.anything() }) ) }) diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts index 569eb61dd72..3c69d229b94 100644 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ b/apps/sim/app/api/v2/credential-connections/route.test.ts @@ -12,6 +12,7 @@ import { import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ execute: vi.fn() })) @@ -147,4 +148,33 @@ describe('POST /api/v2/credential-connections', () => { error: { code: 'NOT_FOUND', message: 'Workspace not found' }, }) }) + + it('returns a conflict when another intent already owns the active provider draft', async () => { + mocks.execute.mockRejectedValueOnce( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credential-connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + ) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: 'A different OAuth connection flow is already active for this provider', + }, + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index a230dec4cc8..4394d86067b 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -19,7 +19,7 @@ describe('createConnectDraft', () => { mockGenerateId.mockReturnValue('new-draft-id') }) - it('preserves the active draft ID when refreshing the same connection intent', async () => { + it('refreshes the expiry without changing an active connection intent', async () => { const expiresAt = new Date('2026-08-13T20:15:00.000Z') dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) @@ -34,13 +34,29 @@ describe('createConnectDraft', () => { expect.objectContaining({ id: 'new-draft-id' }) ) const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as - | { set?: Record } + | { set?: Record; setWhere?: unknown } | undefined expect(conflict?.set).not.toHaveProperty('id') - expect(conflict?.set).toMatchObject({ - displayName: 'Work Gmail', - credentialId: null, - }) + expect(conflict?.set).not.toHaveProperty('displayName') + expect(conflict?.set).not.toHaveProperty('credentialId') + expect(conflict?.setWhere).toBeDefined() expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + + it('fails fast when an active draft has a different connection intent', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'A different OAuth connection flow is already active for this provider', + }) + }) }) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 027f6ae26a2..23a7e4ca34a 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,7 +2,8 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, gt, lt } from 'drizzle-orm' +import { and, eq, gt, isNull, lt } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' @@ -84,14 +85,22 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { expiresAt, createdAt: now }, + setWhere: and( + eq(pendingCredentialDraft.displayName, displayName), + credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + ), }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) - if (!draft) throw new Error('OAuth connect draft insert returned no row') + if (!draft) { + throw new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + } logger.info('Created OAuth connect credential draft', { userId, From e14963685475f86759cc94fa50edf6eb430a68b3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:32:38 -0700 Subject: [PATCH 04/18] feat(credentials): complete v2 credential lifecycle --- apps/docs/openapi-v2-resources.json | 722 ++++++++++++++++-- .../v2/credential-connections/route.test.ts | 180 ----- .../api/v2/credential-providers/route.test.ts | 124 --- .../credentials/[credentialId]/route.test.ts | 77 ++ .../v2/credentials/[credentialId]/route.ts | 30 + .../v2/credentials/connections/route.test.ts | 93 +++ .../connections}/route.ts | 0 .../v2/credentials/providers/route.test.ts | 114 +++ .../providers}/route.ts | 0 apps/sim/app/api/v2/credentials/route.test.ts | 128 +++- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../v2/__tests__/list-pagination.test.ts | 2 +- apps/sim/lib/api/contracts/v2/credentials.ts | 295 ++++++- .../lib/api/contracts/v2/openapi/resources.ts | 132 +++- .../__tests__/webhook-deactivation.test.ts | 39 +- .../application/connection-target.test.ts | 3 +- .../application/connection-target.ts | 22 +- .../lib/credentials/application/operations.ts | 12 + .../credentials/application/presentation.ts | 26 + .../application/provider-catalog.test.ts | 73 +- .../application/provider-catalog.ts | 259 ++++++- .../application/service-account.test.ts | 209 +++++ .../application/service-account.ts | 211 +++++ apps/sim/lib/credentials/deletion.ts | 27 + .../orchestration/credential-create.ts | 109 ++- .../lib/credentials/orchestration/index.ts | 3 + .../credential-visibility.server.ts | 23 +- scripts/check-api-validation-contracts.ts | 4 +- scripts/openapi/documents.test.ts | 4 +- 29 files changed, 2444 insertions(+), 527 deletions(-) delete mode 100644 apps/sim/app/api/v2/credential-connections/route.test.ts delete mode 100644 apps/sim/app/api/v2/credential-providers/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts create mode 100644 apps/sim/app/api/v2/credentials/[credentialId]/route.ts create mode 100644 apps/sim/app/api/v2/credentials/connections/route.test.ts rename apps/sim/app/api/v2/{credential-connections => credentials/connections}/route.ts (100%) create mode 100644 apps/sim/app/api/v2/credentials/providers/route.test.ts rename apps/sim/app/api/v2/{credential-providers => credentials/providers}/route.ts (100%) create mode 100644 apps/sim/lib/credentials/application/presentation.ts create mode 100644 apps/sim/lib/credentials/application/service-account.test.ts create mode 100644 apps/sim/lib/credentials/application/service-account.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 69ae6f3206e..9706338488b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "Discover OAuth providers, connect or reconnect accounts, and list connections without secret material." + "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." }, { "name": "Secrets", @@ -1562,7 +1562,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { @@ -1703,30 +1703,118 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, - "/api/v2/credential-providers": { + "/api/v2/credentials/providers": { "get": { "operationId": "listCredentialProviders", "summary": "List Credential Providers", - "description": "List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace used to evaluate OAuth availability and integration policy.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, - "description": "Workspace used to evaluate OAuth availability and integration policy." + "description": "Workspace used to evaluate credential-provider availability and integration policy." } } ], "responses": { "200": { - "description": "OAuth provider catalog with caller-specific availability.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1770,7 +1858,7 @@ } } }, - "/api/v2/credential-connections": { + "/api/v2/credentials/connections": { "post": { "operationId": "createCredentialConnection", "summary": "Create Credential Connection", @@ -1839,6 +1927,83 @@ } } }, + "/api/v2/credentials/{credentialId}": { + "delete": { + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "parameters": [ + { + "name": "credentialId", + "in": "path", + "required": true, + "description": "Credential to disconnect.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential to disconnect." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace expected to own the credential.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workspace expected to own the credential." + } + } + ], + "responses": { + "200": { + "description": "The credential was disconnected.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteCredentialResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/secrets": { "get": { "operationId": "listSecrets", @@ -4210,78 +4375,245 @@ ] }, "V2CredentialProvider": { - "type": "object", - "properties": { - "serviceId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Stable OAuth service identifier." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "OAuth service display name." - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "OAuth service description." - }, - "providerFamily": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Owning provider family identifier." - }, - "available": { - "type": "boolean", - "description": "Whether this caller can start the OAuth flow in the current deployment." - }, - "supportsReconnect": { - "type": "boolean", - "description": "Whether existing credentials for this service can be reconnected." - }, - "authorizationOptions": { - "minItems": 1, - "maxItems": 10, - "type": "array", - "items": { - "type": "object", - "properties": { - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider identifier accepted by the connection endpoint." + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false }, - "label": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Human-readable authorization-server label." - } + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "service_account", + "description": "Direct service-account credential method." + }, + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." }, - "required": ["providerId", "label"], - "additionalProperties": false + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } + }, + "required": ["value", "label"], + "additionalProperties": false + } + }, + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false + }, + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } }, - "description": "Authorization servers available for this OAuth service." + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false } - }, - "required": [ - "serviceId", - "name", - "description", - "providerFamily", - "available", - "supportsReconnect", - "authorizationOptions" ], - "additionalProperties": false, "title": "Credential Provider", - "description": "An OAuth service that may be connected to a workspace." + "description": "An OAuth or service-account connection method available to a workspace." }, "ListCredentialProvidersResponse": { "type": "object", @@ -4308,11 +4640,12 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List credential providers response", - "description": "OAuth providers and their authorization-server options.", + "description": "OAuth and service-account connection methods.", "examples": [ { "data": [ { + "type": "oauth", "serviceId": "salesforce", "name": "Salesforce", "description": "Connect to Salesforce CRM data and operations.", @@ -4329,12 +4662,209 @@ "label": "Sandbox" } ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] } ], "nextCursor": null } ] }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, "V2CredentialConnectionAuthorization": { "type": "object", "properties": { @@ -4390,7 +4920,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Exact provider ID returned by the credential-provider catalog." + "description": "Exact OAuth provider ID returned by credential-provider discovery." }, "displayName": { "type": "string", @@ -4424,6 +4954,46 @@ "title": "Create credential connection body", "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, "V2Secret": { "type": "object", "properties": { diff --git a/apps/sim/app/api/v2/credential-connections/route.test.ts b/apps/sim/app/api/v2/credential-connections/route.test.ts deleted file mode 100644 index 3c69d229b94..00000000000 --- a/apps/sim/app/api/v2/credential-connections/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ - createCredentialConnection: { - operation: { id: 'credentials.connections.create' }, - execute: mocks.execute, - }, -})) - -import { POST } from '@/app/api/v2/credential-connections/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, - rateLimitSubscription: null, - keyType: 'personal' as const, -} - -describe('POST /api/v2/credential-connections', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - }) - }) - - it('rejects requests that provide both connection targets', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - credentialId: 'credential-1', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('requires a display name for a new connection', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('rejects a display name when reconnecting an existing credential', async () => { - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - credentialId: 'credential-1', - displayName: 'Renamed Gmail', - }), - }) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('returns the short-lived browser URL', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - const response = await POST(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }, - request, - }) - expect(await response.json()).toEqual({ - data: { - authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', - expiresAt: '2026-08-12T20:15:00.000Z', - }, - }) - }) - - it('conceals inaccessible workspaces as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) - - it('returns a conflict when another intent already owns the active provider draft', async () => { - mocks.execute.mockRejectedValueOnce( - new OrchestrationError( - 'conflict', - 'A different OAuth connection flow is already active for this provider' - ) - ) - - const response = await POST( - new NextRequest('http://localhost:3000/api/v2/credential-connections', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: 'Work Gmail', - }), - }) - ) - - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ - error: { - code: 'CONFLICT', - message: 'A different OAuth connection flow is already active for this provider', - }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credential-providers/route.test.ts b/apps/sim/app/api/v2/credential-providers/route.test.ts deleted file mode 100644 index 27de3904a9a..00000000000 --- a/apps/sim/app/api/v2/credential-providers/route.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * @vitest-environment node - */ -import { - V2_OPERATION_RATE_LIMIT_ALLOWED, - V2_PREAUTH_RATE_LIMIT_ALLOWED, - v2ApiKeyAuthModuleMock, - v2GateModuleMock, - v2RateLimiterModuleMock, - v2RouteMocks, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { WorkspaceApiKeyScopeAuthorizationError } from '@/lib/core/application' - -const mocks = vi.hoisted(() => ({ execute: vi.fn() })) - -vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) -vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) -vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) -vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ - listCredentialProviders: { - operation: { id: 'credentials.providers.list' }, - execute: mocks.execute, - }, -})) - -import { GET } from '@/app/api/v2/credential-providers/route' - -const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -const auth = { - principal: { - kind: 'workspace_api_key' as const, - workspaceId: WORKSPACE_ID, - keyId: 'key-1', - }, - rolloutUserId: 'billing-owner-1', - rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, - rateLimitSubscription: null, - keyType: 'workspace' as const, -} - -describe('GET /api/v2/credential-providers', () => { - beforeEach(() => { - vi.clearAllMocks() - v2RouteMocks.authenticate.mockResolvedValue(auth) - v2RouteMocks.gate.mockResolvedValue(null) - v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) - v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ - providers: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - }) - }) - - it('returns the full provider catalog in one page', async () => { - const request = new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - const response = await GET(request) - - expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ - principal: auth.principal, - input: { workspaceId: WORKSPACE_ID }, - request, - }) - expect(await response.json()).toEqual({ - data: [ - { - serviceId: 'salesforce', - name: 'Salesforce', - description: 'Connect Salesforce.', - providerFamily: 'salesforce', - available: true, - supportsReconnect: true, - authorizationOptions: [ - { providerId: 'salesforce', label: 'Production' }, - { providerId: 'salesforce-sandbox', label: 'Sandbox' }, - ], - }, - ], - nextCursor: null, - }) - }) - - it('rejects query parameters it does not implement', async () => { - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}&limit=1` - ) - ) - - expect(response.status).toBe(400) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('conceals a workspace-key scope mismatch as not found', async () => { - mocks.execute.mockRejectedValueOnce(new WorkspaceApiKeyScopeAuthorizationError()) - - const response = await GET( - new NextRequest( - `http://localhost:3000/api/v2/credential-providers?workspaceId=${WORKSPACE_ID}` - ) - ) - - expect(response.status).toBe(404) - expect(await response.json()).toEqual({ - error: { code: 'NOT_FOUND', message: 'Workspace not found' }, - }) - }) -}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts new file mode 100644 index 00000000000..38362bef21f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.execute, + }, +})) + +import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('DELETE /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + }) + + it('disconnects a credential through the application operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, { + params: Promise.resolve({ credentialId: 'credential-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + request, + }) + }) + + it('requires the asserted workspace scope', async () => { + const response = await DELETE( + new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + method: 'DELETE', + }), + { params: Promise.resolve({ credentialId: 'credential-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..0baea423259 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -0,0 +1,30 @@ +import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Credential not found', +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + credentialId: params.credentialId, + }), + useCase: deleteCredentialUseCase, + present: ({ credential }) => ({ data: { id: credential.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.test.ts b/apps/sim/app/api/v2/credentials/connections/route.test.ts new file mode 100644 index 00000000000..1946b7fb81c --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credentials/connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credentials/connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('creates a browser entrypoint for a named OAuth credential', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + }) + + it('requires a display name for new OAuth connections', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-connections/route.ts rename to apps/sim/app/api/v2/credentials/connections/route.ts diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts new file mode 100644 index 00000000000..bec5f7126c8 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credentials/providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const providers = [ + { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'service_account' as const, + serviceId: 'salesforce-service-account', + providerId: 'salesforce-service-account', + name: 'Salesforce integration user app', + description: 'Connect Salesforce with an integration user app.', + providerFamily: 'salesforce', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: ['client_credentials'], + }, + ], + }, +] + +describe('GET /api/v2/credentials/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ providers }) + }) + + it('returns OAuth and service-account connection methods', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: providers, nextCursor: null }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('rejects unsupported pagination instead of ignoring it', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credential-providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts similarity index 100% rename from apps/sim/app/api/v2/credential-providers/route.ts rename to apps/sim/app/api/v2/credentials/providers/route.ts diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..3857fc31409 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,7 +13,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - execute: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -23,13 +24,20 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { operation: { id: 'credentials.connections.list' }, - execute: mocks.execute, + execute: mocks.list, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + createServiceAccountCredentialUseCase: { + operation: { id: 'credentials.service_accounts.create' }, + execute: mocks.create, }, })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { GET } from '@/app/api/v2/credentials/route' +import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const auth = { @@ -67,7 +75,7 @@ describe('GET /api/v2/credentials', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: null, sortBy: 'createdAt', @@ -81,7 +89,7 @@ describe('GET /api/v2/credentials', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('calls the application operation with the workspace principal', async () => { @@ -91,7 +99,7 @@ describe('GET /api/v2/credentials', () => { const response = await GET(request) expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: { workspaceId: WORKSPACE_ID, @@ -114,7 +122,7 @@ describe('GET /api/v2/credentials', () => { * map of param names and stays green when a route drops the stamp entirely. */ it('refuses a cursor minted under a different filter', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -129,7 +137,7 @@ describe('GET /api/v2/credentials', () => { const { nextCursor } = await minted.json() expect(nextCursor).toEqual(expect.any(String)) - mocks.execute.mockClear() + mocks.list.mockClear() const replayed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` @@ -138,11 +146,11 @@ describe('GET /api/v2/credentials', () => { expect(replayed.status).toBe(400) expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('resumes a cursor replayed under the filters it was minted with', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -156,7 +164,7 @@ describe('GET /api/v2/credentials', () => { ) const { nextCursor } = await minted.json() - mocks.execute.mockClear() + mocks.list.mockClear() const resumed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` @@ -164,7 +172,7 @@ describe('GET /api/v2/credentials', () => { ) expect(resumed.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ search: 'zoom', @@ -202,7 +210,7 @@ describe('GET /api/v2/credentials', () => { }) it('hides repository errors that may contain secret details', async () => { - mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + mocks.list.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) @@ -214,3 +222,97 @@ describe('GET /api/v2/credentials', () => { }) }) }) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + keyType: 'personal', + }) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.create.mockResolvedValue({ + credential: { ...credential, encryptedServiceAccountKey: 'must-not-leak' }, + created: true, + hasServiceAccountKey: true, + role: 'admin', + auditMetadata: {}, + }) + }) + + it('creates a verified service-account credential without returning secrets', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }), + }) + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toMatchObject({ + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + providerId: 'zoom-service-account', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('client-secret') + expect(JSON.stringify(body)).not.toContain('must-not-leak') + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + description: undefined, + id: undefined, + serviceAccountJson: undefined, + apiToken: undefined, + domain: undefined, + signingSecret: undefined, + botToken: undefined, + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }, + request, + }) + }) + + it('rejects an unknown service-account provider before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'made-up-service-account', + serviceAccountJson: '{}', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index bccfffccbe2..207efb65ec8 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,39 +1,26 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateServiceAccountCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { cursorScopeKey } from '@/lib/api/cursor-binding' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { toV2Credential } from '@/lib/credentials/application/presentation' +import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} +const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { @@ -56,7 +43,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: credentialOperations.listConnections, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: credentialWorkspaceErrorPolicy, mapInput: ({ query }) => ({ ...query, cursorKeys: readSortedCursor( @@ -77,3 +64,18 @@ export const GET = defineV2JsonRoute({ ), }), }) + +/** POST /api/v2/credentials — Create and verify a service-account credential. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateServiceAccountCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createServiceAccount, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialWorkspaceErrorPolicy, + mapInput: ({ body }) => body, + useCase: createServiceAccountCredentialUseCase, + present: ({ credential, hasServiceAccountKey, role }) => ({ + data: toV2Credential({ ...credential, hasServiceAccountKey, role }), + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 4ce9499de71..9dd1e8aee32 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -83,7 +83,7 @@ const PAGED_LISTS = [ * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ - 'GET /api/v2/credential-providers', + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 60d537b2754..c7a3ab5c7fd 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -10,6 +10,11 @@ import { v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -45,27 +50,69 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output -export const v2CredentialProviderAuthorizationOptionSchema = z.object({ - providerId: z - .string() - .min(1, 'providerId cannot be empty') - .max(255, 'providerId must be at most 255 characters') - .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), - label: z.string().min(1).max(255).describe('Human-readable authorization-server label.'), -}) +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() export type V2CredentialProviderAuthorizationOption = z.output< typeof v2CredentialProviderAuthorizationOptionSchema > -export const v2CredentialProviderSchema = z +export const v2CredentialProviderFieldOptionSchema = z .object({ - serviceId: z.string().min(1).max(255).describe('Stable OAuth service identifier.'), - name: z.string().min(1).max(255).describe('OAuth service display name.'), - description: z.string().min(1).max(1000).describe('OAuth service description.'), - providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), - available: z - .boolean() - .describe('Whether this caller can start the OAuth flow in the current deployment.'), + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, supportsReconnect: z .boolean() .describe('Whether existing credentials for this service can be reconnected.'), @@ -75,10 +122,39 @@ export const v2CredentialProviderSchema = z .max(10) .describe('Authorization servers available for this OAuth service.'), }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) .meta({ id: 'V2CredentialProvider', title: 'Credential Provider', - description: 'An OAuth service that may be connected to a workspace.', + description: 'An OAuth or service-account connection method available to a workspace.', }) export type V2CredentialProvider = z.output @@ -104,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -122,7 +193,7 @@ export const v2ListCredentialsContract = defineRouteContract({ export const v2ListCredentialProvidersQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe( - 'Workspace used to evaluate OAuth availability and integration policy.' + 'Workspace used to evaluate credential-provider availability and integration policy.' ), }) .strict() @@ -130,7 +201,7 @@ export type V2ListCredentialProvidersQuery = z.output { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 377a1cb7442..7a420995f46 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,5 +1,7 @@ import { v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, v2ListCredentialProvidersContract, v2ListCredentialsContract, } from '@/lib/api/contracts/v2/credentials' @@ -171,6 +173,7 @@ const CREDENTIAL_EXAMPLE = { } as const const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect to Salesforce CRM data and operations.', @@ -183,6 +186,44 @@ const CREDENTIAL_PROVIDER_EXAMPLE = { ], } as const +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + const CREDENTIAL_CONNECTION_EXAMPLE = { authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', expiresAt: '2026-06-20T14:17:11.000Z', @@ -806,7 +847,7 @@ const declaredRoutes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -831,9 +872,9 @@ const declaredRoutes = [ resourceOperation('Credentials', { operationId: 'listCredentialProviders', summary: 'List Credential Providers', - description: `List catalogued OAuth services and whether each is available to the caller in this workspace and deployment. Authorization options contain the exact provider IDs accepted by the connection endpoint. ${FULL_SET_LIST}`, + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, errors: RESOURCE_ERRORS, - success: { description: 'OAuth provider catalog with caller-specific availability.' }, + success: { description: 'Credential provider catalog with caller-specific availability.' }, }), { query: documentedSchema( @@ -846,8 +887,55 @@ const declaredRoutes = [ v2ListCredentialProvidersContract.response.schema, 'ListCredentialProvidersResponse', 'List credential providers response', - 'OAuth providers and their authorization-server options.', - [{ data: [CREDENTIAL_PROVIDER_EXAMPLE], nextCursor: null }] + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] ), } ), @@ -861,6 +949,7 @@ const declaredRoutes = [ success: { description: 'A short-lived browser authorization URL.' }, }), { + query: v2CreateCredentialConnectionContract.query, body: documentedSchema( v2CreateCredentialConnectionContract.body, 'CreateCredentialConnectionBody', @@ -876,6 +965,37 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + operationId: 'deleteCredential', + summary: 'Disconnect Credential', + description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -1026,7 +1146,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ { name: 'Credentials', description: - 'Discover OAuth providers, connect or reconnect accounts, and list connections without secret material.', + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..cdb6849481f 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,40 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('fails fast if the authorized credential disappears before deletion commits', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).rejects.toThrow('Credential disappeared during deletion') + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts index ce3a6805483..143a068961e 100644 --- a/apps/sim/lib/credentials/application/connection-target.test.ts +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/credentials/application/provider-catalog', () => ({ listCredentialProviderCatalog: mocks.listCatalog, - requireAvailableCredentialProvider: ( + requireAvailableOAuthCredentialProvider: ( catalog: Array<{ available: boolean authorizationOptions: Array<{ providerId: string }> @@ -59,6 +59,7 @@ const context = { billedAccountUserId: 'billing-owner-1', } const salesforceProvider = { + type: 'oauth' as const, serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 6365988099e..8783d526463 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -3,16 +3,16 @@ import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { - type CredentialProviderCatalogEntry, listCredentialProviderCatalog, - requireAvailableCredentialProvider, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, } from '@/lib/credentials/application/provider-catalog' import { getWorkspaceCredential } from '@/lib/credentials/queries' import { credentialProviderMatchesService } from '@/lib/oauth/utils' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' export interface ResolvedCredentialConnectionTarget { - provider: CredentialProviderCatalogEntry + provider: OAuthCredentialProviderCatalogEntry providerId: string credentialId?: string displayName?: string @@ -32,7 +32,7 @@ export async function resolveCredentialConnectionTarget(params: { const catalog = await listCredentialProviderCatalog(principal, context) if (providerId) { return { - provider: requireAvailableCredentialProvider(catalog, providerId), + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), providerId, } } @@ -61,11 +61,15 @@ export async function resolveCredentialConnectionTarget(params: { ) } - const provider = catalog.find((entry) => - credentialProviderMatchesService(credentialProviderId, { - providerId: entry.authorizationOptions[0].providerId, - additionalProviderIds: entry.authorizationOptions.slice(1).map((option) => option.providerId), - }) + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 7432c1c7430..4dd53eb7dd5 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -19,6 +19,18 @@ export const credentialOperations = { workspaceApiKey: 'deny', principalKinds: ['personal_api_key'], }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), + delete: defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['personal_api_key'], + }), launchConnection: defineWorkspaceOperation({ id: 'credentials.connections.launch', minimumRole: 'write', diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..efbf73c0d07 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,26 @@ +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 51aece09f81..08a452dd5c2 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -44,7 +44,11 @@ vi.mock('@/lib/oauth/utils', () => ({ getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, })) -import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' const personalPrincipal = { kind: 'personal_api_key' as const, @@ -74,11 +78,12 @@ const services = [ authType: 'oauth' as const, }, { - serviceId: 'service-account-only', - providerId: 'service-account-only', - name: 'Service account', - description: 'Not OAuth.', - baseProvider: 'test', + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', authType: 'service_account' as const, }, ] @@ -98,6 +103,8 @@ describe('listCredentialProviderCatalog', () => { }) mocks.createVisibility.mockReturnValue({ isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', }) mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { if (serviceId === 'salesforce') { @@ -118,6 +125,7 @@ describe('listCredentialProviderCatalog', () => { expect(catalog).toEqual([ { + type: 'oauth', serviceId: 'salesforce', name: 'Salesforce', description: 'Connect Salesforce.', @@ -130,6 +138,7 @@ describe('listCredentialProviderCatalog', () => { ], }, { + type: 'oauth', serviceId: 'trello', name: 'Trello', description: 'Connect Trello.', @@ -138,6 +147,28 @@ describe('listCredentialProviderCatalog', () => { supportsReconnect: false, authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, ]) expect(mocks.createVisibility).toHaveBeenCalledWith( expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) @@ -174,3 +205,33 @@ describe('listCredentialProviderCatalog', () => { ) }) }) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 760cda016d0..90e8d49d500 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -2,7 +2,21 @@ import type { Principal } from '@sim/auth/principal' import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -12,21 +26,195 @@ export interface CredentialProviderAuthorizationOption { label: string } -export interface CredentialProviderCatalogEntry { +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' serviceId: string name: string description: string providerFamily: string available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' supportsReconnect: boolean authorizationOptions: CredentialProviderAuthorizationOption[] } +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + interface CredentialProviderCatalogContext { workspaceId: string workspaceOrganizationId: string | null } +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + function principalUserId(principal: Principal): string | undefined { if (principal.kind === 'session' || principal.kind === 'personal_api_key') { return principal.userId @@ -60,14 +248,15 @@ export async function listCredentialProviderCatalog( ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), }), ]) - const oauthServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') const visibility = createIntegrationCredentialVisibility({ allowedIntegrationTypes: allowedIntegrations, blockVisibility, - oauthServices, + oauthServices: services, }) - return oauthServices.map((service) => { + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { const config = getServiceConfigByServiceId(service.serviceId) if (!config) { throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) @@ -85,6 +274,7 @@ export async function listCredentialProviderCatalog( }) return { + type: 'oauth', serviceId: service.serviceId, name: service.name, description: service.description, @@ -94,14 +284,47 @@ export async function listCredentialProviderCatalog( authorizationOptions, } }) + + const serviceAccountOwners = new Map() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] } -export function requireAvailableCredentialProvider( +export function requireAvailableOAuthCredentialProvider( catalog: readonly CredentialProviderCatalogEntry[], providerId: string -): CredentialProviderCatalogEntry { - const provider = catalog.find((entry) => - entry.authorizationOptions.some((option) => option.providerId === providerId) +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) ) if (!provider) { throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) @@ -111,3 +334,23 @@ export function requireAvailableCredentialProvider( } return provider } + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..37a384a6752 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,209 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + delete: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteConnectionCredential: mocks.delete, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential }) + expect(mocks.delete).toHaveBeenCalledWith({ + credentialId: credential.id, + workspaceId: WORKSPACE_ID, + reason: 'user_delete', + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..ac7e766cc47 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,211 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteConnectionCredential, +} from '@/lib/credentials/orchestration' +import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +function principalUserId(principal: Extract): string { + return principal.userId +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: principalUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal)) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + principalUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +interface CredentialApplicationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credential: CredentialRow +} + +export interface DeleteCredentialInput { + workspaceId: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow +} + +async function resolveCredentialContext( + input: DeleteCredentialInput +): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + const credential = await getWorkspaceCredential({ + workspaceId: workspace.workspaceId, + credentialId: input.credentialId, + }) + if (!credential || !['oauth', 'service_account'].includes(credential.type)) { + throw new OrchestrationError('not_found', 'Credential not found') + } + return { ...workspace, credential } +} + +export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.delete, + resolveContext: async ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialContext(input), + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + const userId = principalUserId(principal) + const actor = await getCredentialActorContext(context.credential.id, userId) + if (!actor.credential || !actor.hasWorkspaceAccess) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + + await deleteConnectionCredential({ + credentialId: input.credentialId, + workspaceId: context.workspaceId, + reason: 'user_delete', + }) + return { credential: context.credential } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + }), + afterSuccess: ({ principal, context, result }) => { + captureServerEvent( + principalUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type as 'oauth' | 'service_account', + provider_id: result.credential.providerId ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..e42081e57fc 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,27 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 7a09f8a4f16..785f3320c7f 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -5,9 +5,9 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -81,7 +81,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -95,6 +95,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record } interface ExistingCredentialSourceParams { @@ -190,14 +192,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -332,7 +337,7 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { @@ -484,37 +489,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -570,6 +545,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff0a3bd283f..bab184fc3a4 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -32,7 +32,10 @@ import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 5a40d039ee0..6665ac74a79 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1107, - zodRoutes: 1107, + totalRoutes: 1108, + zodRoutes: 1108, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 191fb41000a..2fb9c71e064 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 24], + ['apps/docs/openapi-v2-resources.json', 26], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(137) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => { From 7fcf26fca5be1d0c6c7c519af90e0aad4d5fd8f8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:41:06 -0700 Subject: [PATCH 05/18] fix(credentials): make disconnect idempotent --- .../__tests__/webhook-deactivation.test.ts | 7 ++-- .../application/service-account.test.ts | 14 +++++++- .../application/service-account.ts | 35 +++++++++++-------- apps/sim/lib/credentials/deletion.ts | 17 +++++---- 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index cdb6849481f..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -53,18 +53,19 @@ describe('deleteConnectionCredential', () => { it('deletes exactly one credential within its canonical workspace scope', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: 'credential-1', workspaceId: 'workspace-1', reason: 'user_delete', }) + expect(deleted).toBe(true) expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') }) - it('fails fast if the authorized credential disappears before deletion commits', async () => { + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) await expect( @@ -73,6 +74,6 @@ describe('deleteConnectionCredential', () => { workspaceId: 'workspace-1', reason: 'user_delete', }) - ).rejects.toThrow('Credential disappeared during deletion') + ).resolves.toBe(false) }) }) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts index 37a384a6752..9da01986a6d 100644 --- a/apps/sim/lib/credentials/application/service-account.test.ts +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -92,6 +92,7 @@ describe('credential service-account application operations', () => { auditMetadata: { tenantId: 'tenant-1' }, }) mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.delete.mockResolvedValue(true) mocks.requireProvider.mockReturnValue({ type: 'service_account', providerId: 'zoom-service-account', @@ -199,11 +200,22 @@ describe('credential service-account application operations', () => { input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, }) - expect(result).toEqual({ credential }) + expect(result).toEqual({ credential, deleted: true }) expect(mocks.delete).toHaveBeenCalledWith({ credentialId: credential.id, workspaceId: WORKSPACE_ID, reason: 'user_delete', }) }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.delete.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) }) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index ac7e766cc47..5954a910b2e 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -141,6 +141,7 @@ export interface DeleteCredentialInput { export interface DeleteCredentialResult { credential: CredentialRow + deleted: boolean } async function resolveCredentialContext( @@ -176,27 +177,31 @@ export const deleteCredentialUseCase = defineAuthorizedWorkspaceUseCase({ ) } - await deleteConnectionCredential({ + const deleted = await deleteConnectionCredential({ credentialId: input.credentialId, workspaceId: context.workspaceId, reason: 'user_delete', }) - return { credential: context.credential } + return { credential: context.credential, deleted } }, - projectAudit: ({ result }) => ({ - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: result.credential.id, - resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, - metadata: { - reason: 'user_delete', - credentialType: result.credential.type, - providerId: result.credential.providerId, - accountId: result.credential.accountId, - }, - }), + projectAudit: ({ result }) => + result.deleted + ? { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + metadata: { + reason: 'user_delete', + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + }, + } + : [], afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return captureServerEvent( principalUserId(principal), 'credential_deleted', diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index e42081e57fc..f16902ddf04 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -80,7 +80,7 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< /** Clears references and deletes one connection without surface audit attribution. */ export async function deleteConnectionCredential( params: DeleteConnectionCredentialParams -): Promise { +): Promise { const { credentialId, workspaceId } = params await clearCredentialRefs(credentialId, workspaceId) const deleted = await db @@ -89,13 +89,16 @@ export async function deleteConnectionCredential( and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) ) .returning({ id: schema.credential.id }) - if (deleted.length !== 1) throw new Error('Credential disappeared during deletion') + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') - logger.info('Deleted credential', { - credentialId, - workspaceId, - reason: params.reason, - }) + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 } /** From cf680e6550bcb0d510591ef204e07e11e59fe45f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 13 Aug 2026 13:49:24 -0700 Subject: [PATCH 06/18] fix(credentials): stabilize oauth draft retries --- .../create-credential-connection.test.ts | 2 ++ .../create-credential-connection.ts | 1 + .../sim/lib/credentials/connect-draft.test.ts | 23 ++++++++++++++++++- apps/sim/lib/credentials/connect-draft.ts | 15 +++++++----- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index b934755f0e5..544edd2130b 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -97,6 +97,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: undefined, displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', @@ -123,6 +124,7 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: 'credential-1', displayName: 'Existing Gmail', + displayNameDefinesIntent: false, }) }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 513e2b21c85..4aaae28a61c 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -42,6 +42,7 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ providerId: target.providerId, credentialId: target.credentialId, displayName, + displayNameDefinesIntent: input.providerId !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index 4394d86067b..539a7cac8c5 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ @@ -28,6 +28,7 @@ describe('createConnectDraft', () => { workspaceId: 'workspace-1', providerId: 'google-email', displayName: 'Work Gmail', + displayNameDefinesIntent: true, }) expect(dbChainMockFns.values).toHaveBeenCalledWith( @@ -43,6 +44,26 @@ describe('createConnectDraft', () => { expect(result).toEqual({ id: 'active-draft-id', expiresAt }) }) + it('refreshes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + + expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( + schemaMock.pendingCredentialDraft.displayName, + 'Renamed Gmail' + ) + }) + it('fails fast when an active draft has a different connection intent', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 23a7e4ca34a..6c2565e3d55 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -29,6 +29,8 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean }): Promise { const { userId, workspaceId, providerId, credentialId } = params @@ -67,6 +69,12 @@ export async function createConnectDraft(params: { and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) const id = generateId() + const sameTarget = credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + const sameIntent = params.displayNameDefinesIntent + ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) + : sameTarget const [draft] = await db .insert(pendingCredentialDraft) .values({ @@ -86,12 +94,7 @@ export async function createConnectDraft(params: { pendingCredentialDraft.workspaceId, ], set: { expiresAt, createdAt: now }, - setWhere: and( - eq(pendingCredentialDraft.displayName, displayName), - credentialId - ? eq(pendingCredentialDraft.credentialId, credentialId) - : isNull(pendingCredentialDraft.credentialId) - ), + setWhere: sameIntent, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) From b8d4a95829d277ca021cfe05e146d0d6a5007463 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 10:53:48 -0700 Subject: [PATCH 07/18] fix(credentials): bind oauth callbacks to drafts --- .../app/api/auth/instagram/authorize/route.ts | 21 ++++- .../api/auth/oauth2/authorize/route.test.ts | 27 ++++-- .../app/api/auth/oauth2/authorize/route.ts | 32 +++---- .../auth/oauth2/callback/instagram/route.ts | 7 ++ .../api/auth/oauth2/shopify/store/route.ts | 3 + .../app/api/auth/shopify/authorize/route.ts | 20 ++++- .../app/api/auth/trello/authorize/route.ts | 17 +++- apps/sim/app/api/auth/trello/store/route.ts | 4 + .../lib/api/contracts/oauth-connections.ts | 14 +-- apps/sim/lib/auth/auth.ts | 13 ++- .../lib/credentials/draft-processor.test.ts | 86 +++++++++++++++++++ apps/sim/lib/credentials/draft-processor.ts | 37 +++++--- apps/sim/lib/credentials/oauth-draft-state.ts | 1 + 13 files changed, 237 insertions(+), 45 deletions(-) create mode 100644 apps/sim/lib/credentials/draft-processor.test.ts create mode 100644 apps/sim/lib/credentials/oauth-draft-state.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index b33a0c0c510..7d2c0270796 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -18,6 +18,7 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -34,18 +35,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeInstagramContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl, workspaceId } = parsed.data.query + const { returnUrl, workspaceId, draftId } = parsed.data.query + let credentialDraftId = draftId if (workspaceId) { const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.canWrite) { return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) } - await createConnectDraft({ + const draft = await createConnectDraft({ userId: session.user.id, workspaceId, providerId: 'instagram', }) + credentialDraftId = draft.id } const baseUrl = getBaseUrl() @@ -68,6 +71,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) + if (credentialDraftId) { + response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index c9bb3e8e9dd..a39d6f8f0a2 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -165,7 +165,7 @@ describe('OAuth2 authorize route', () => { expect.objectContaining({ body: { providerId: 'google-email', - callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected`, + callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected&credentialDraftId=draft-1`, errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, }, }) @@ -191,7 +191,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(authorizeRequest({ draftId: 'draft-1' })) expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected` + `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected&draftId=draft-1` ) expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() }) @@ -216,6 +216,13 @@ describe('OAuth2 authorize route', () => { expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( expect.objectContaining({ setWhere: expect.anything() }) ) + expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + callbackURL: `${BASE_URL}/workspace?credentialDraftId=draft-1`, + }), + }) + ) }) it('numbers the draft display name when the default collides with an existing credential', async () => { @@ -325,18 +332,26 @@ describe('OAuth2 authorize route', () => { ) }) - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { + it('binds custom-provider reconnects to the exact draft', async () => { + setEnv({ + TRELLO_API_KEY: 'trello-key', + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }) for (const providerId of ['trello', 'shopify']) { + mockGetCredentialActorContext.mockResolvedValue( + oauthCredentialActor({ credential: { providerId } }) + ) const response = await GET( authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) ) expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` + `${BASE_URL}/api/auth/${providerId}/authorize?returnUrl=https%3A%2F%2Fsim.test%2Fworkspace&draftId=draft-1` ) } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledTimes(2) + expect(dbChainMockFns.values).toHaveBeenCalledTimes(2) expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 49d8d8c7fcf..98543d53099 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -10,6 +10,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCredentialActorContext } from '@/lib/credentials/access' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') @@ -36,6 +37,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query let fromConnectionDraft = false + let connectionDraftId: string | undefined if (draftId) { try { const sessionId = session.session?.id @@ -52,6 +54,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId = draft.providerId workspaceId = draft.workspaceId credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id fromConnectionDraft = true } catch (error) { if (!(error instanceof OrchestrationError)) throw error @@ -86,22 +89,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect( - `${baseUrl}/workspace?error=credential_reconnect_unsupported` - ) - } - // Reconnect: the OAuth callback will rebind this credential to the fresh // account, so require the same credential-admin access as the draft POST // route — workspace write alone must not be enough to swap someone's tokens. @@ -139,25 +126,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { requireConfiguredOAuthClient(providerId) if (!draftId) { - await createConnectDraft({ + const draft = await createConnectDraft({ userId, workspaceId, providerId, credentialId, displayName: reconnectDisplayName, }) + connectionDraftId = draft.id + } + + if (!connectionDraftId) { + throw new Error('OAuth authorization is missing its credential draft id') } if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('draftId', connectionDraftId) return NextResponse.redirect(authorizeUrl) } + const stateCallbackUrl = new URL(callbackURL) + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) + const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, - callbackURL, + callbackURL: stateCallbackUrl.toString(), ...(fromConnectionDraft ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } : {}), diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 4aea1372f83..b5dfa269d49 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -33,11 +33,16 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' function clearOAuthCookies(response: NextResponse) { response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) return response } @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { code, state, error, error_reason, error_description } = parsed.data.query + const draftId = request.cookies.get(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE)?.value if (error) { logger.warn('Instagram OAuth denied by user', { @@ -296,6 +302,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'instagram', accountId: persisted.id, diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 182989f917a..6ebe44de6c6 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -41,6 +41,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + const draftId = request.cookies.get('shopify_credential_draft_id')?.value if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format in cookie', { shopDomain }) @@ -121,6 +122,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'shopify', accountId: persisted.id, @@ -139,6 +141,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_pending_shop') response.cookies.delete('shopify_pending_scope') response.cookies.delete('shopify_return_url') + response.cookies.delete('shopify_credential_draft_id') return response } catch (error) { diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index d2d3a5401e4..63475a0cf76 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -32,13 +32,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const query = shopifyAuthorizeQuerySchema.parse({ shop: request.nextUrl.searchParams.get('shop') || undefined, returnUrl: request.nextUrl.searchParams.get('returnUrl') || undefined, + draftId: request.nextUrl.searchParams.get('draftId') || undefined, }) - const { shop: shopDomain, returnUrl } = query + const { shop: shopDomain, returnUrl, draftId } = query if (!shopDomain) { const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? encodeURIComponent(returnUrl) : '' const returnUrlJsLiteral = JSON.stringify(safeReturnUrl) + const draftIdJsLiteral = JSON.stringify(draftId ?? '') return new NextResponse( ` @@ -127,6 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { @@ -205,6 +211,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { path: '/', }) + if (draftId) { + response.cookies.set('shopify_credential_draft_id', draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 10, + path: '/', + }) + } else { + response.cookies.delete('shopify_credential_draft_id') + } + if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set('shopify_return_url', returnUrl, { httpOnly: true, diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..c24cd32d61d 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -16,6 +16,7 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -28,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query + const { returnUrl: requestedReturnUrl, draftId } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -60,6 +61,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) + if (draftId) { + response.cookies.set(TRELLO_CREDENTIAL_DRAFT_COOKIE, draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + path: TRELLO_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: TRELLO_CREDENTIAL_DRAFT_COOKIE, + path: TRELLO_STATE_COOKIE_PATH, + }) + } if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { httpOnly: true, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 12233c934a4..aa99cc2cb98 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -18,11 +18,13 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + response.cookies.delete({ name: TRELLO_CREDENTIAL_DRAFT_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } @@ -37,6 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(storeTrelloTokenContract, request, {}) if (!parsed.success) return parsed.response const { token, state } = parsed.data.body + const draftId = request.cookies.get(TRELLO_CREDENTIAL_DRAFT_COOKIE)?.value const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value if (!cookieState || cookieState !== state) { @@ -138,6 +141,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (persisted) { try { await processCredentialDraft({ + draftId, userId: session.user.id, providerId: 'trello', accountId: persisted.id, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 3e184df791c..74f7afea2c9 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -47,12 +47,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -129,6 +135,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -218,6 +225,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -264,11 +272,7 @@ export const instagramCallbackContract = defineRouteContract({ export const authorizeOAuth2QuerySchema = z .object({ - draftId: z - .string() - .min(1, 'draftId is required') - .max(255, 'draftId must be at most 255 characters') - .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), providerId: z.string().min(1, 'providerId is required').optional(), workspaceId: workspaceIdSchema.optional(), callbackURL: z.string().min(1).optional(), diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..fe4c0089a3c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -97,6 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -524,7 +525,17 @@ export const auth = betterAuth({ } try { + const oauthState = await getOAuthState() + const rawCallbackUrl = oauthState?.callbackURL + if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + const draftId = rawCallbackUrl + ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? + undefined) + : undefined await processCredentialDraft({ + draftId, userId: account.userId, providerId: account.providerId, accountId: account.id, diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..6ed91fcdb73 --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { processCredentialDraft } from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..b7e418db28f 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -10,30 +10,45 @@ import { const logger = createLogger('CredentialDraftProcessor') interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params - const [draft] = await db + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } + + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts if (!draft) return diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts new file mode 100644 index 00000000000..3dd068a1bed --- /dev/null +++ b/apps/sim/lib/credentials/oauth-draft-state.ts @@ -0,0 +1 @@ +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' From 75d3327cbfd8d2d1ae4fd30564762e1287e73c2a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 11:08:22 -0700 Subject: [PATCH 08/18] fix(credentials): fail closed on oauth completion --- .../auth/oauth2/callback/instagram/route.ts | 19 ++++++++----------- .../api/auth/oauth2/shopify/store/route.ts | 19 ++++++++----------- apps/sim/app/api/auth/trello/store/route.ts | 19 ++++++++----------- apps/sim/lib/auth/auth.ts | 6 ++++-- apps/sim/lib/credentials/draft-hooks.ts | 14 ++++---------- .../lib/credentials/draft-processor.test.ts | 19 +++++++++++++++++++ apps/sim/lib/credentials/draft-processor.ts | 9 ++++++++- 7 files changed, 59 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index b5dfa269d49..19284a950fc 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -299,18 +299,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'instagram', - accountId: persisted.id, - }) - } catch (draftError) { - logger.error('Failed to process credential draft for Instagram', { error: draftError }) - } + if (!persisted) { + throw new Error(`Instagram OAuth account ${igUserId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 6ebe44de6c6..ca58ab7c8f4 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -119,18 +119,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Shopify', { error }) - } + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'shopify', + accountId: persisted.id, + }) const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index aa99cc2cb98..22d9a04aefa 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -138,18 +138,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - draftId, - userId: session.user.id, - providerId: 'trello', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Trello', { error }) - } + if (!persisted) { + throw new Error(`Trello OAuth account ${trelloUser.id} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'trello', + accountId: persisted.id, + }) return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index fe4c0089a3c..dc4ba62ca70 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -524,18 +524,19 @@ export const auth = betterAuth({ } } + let credentialDraftId: string | undefined try { const oauthState = await getOAuthState() const rawCallbackUrl = oauthState?.callbackURL if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { throw new Error('OAuth state callback URL must be a string') } - const draftId = rawCallbackUrl + credentialDraftId = rawCallbackUrl ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined) : undefined await processCredentialDraft({ - draftId, + draftId: credentialDraftId, userId: account.userId, providerId: account.providerId, accountId: account.id, @@ -546,6 +547,7 @@ export const auth = betterAuth({ providerId: account.providerId, error, }) + if (credentialDraftId) throw error } try { diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..c1aed407dca 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -121,10 +121,7 @@ export async function handleReconnectCredential(params: { .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId @@ -144,12 +141,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index 6ed91fcdb73..9eb88bc1953 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -83,4 +83,23 @@ describe('processCredentialDraft', () => { expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() expect(mockHandleReconnectCredential).not.toHaveBeenCalled() }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7e418db28f..fc3637845f0 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -50,7 +50,14 @@ export async function processCredentialDraft(params: ProcessCredentialDraftParam const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() From c9a51aee395bffe51558cb25d27ade3011def217 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 11:33:36 -0700 Subject: [PATCH 09/18] fix(credentials): bind shopify completion to oauth state --- .../oauth2/callback/shopify/route.test.ts | 97 +++++++++++++++ .../api/auth/oauth2/callback/shopify/route.ts | 76 ++++++------ .../api/auth/oauth2/shopify/store/route.ts | 91 ++------------ .../app/api/auth/shopify/authorize/route.ts | 43 ++----- apps/sim/lib/oauth/shopify-state.test.ts | 80 ++++++++++++ apps/sim/lib/oauth/shopify-state.ts | 102 ++++++++++++++++ apps/sim/lib/oauth/shopify.ts | 114 ++++++++++++++++++ 7 files changed, 453 insertions(+), 150 deletions(-) create mode 100644 apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts create mode 100644 apps/sim/lib/oauth/shopify-state.test.ts create mode 100644 apps/sim/lib/oauth/shopify-state.ts create mode 100644 apps/sim/lib/oauth/shopify.ts diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts new file mode 100644 index 00000000000..664a50bc714 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } = + vi.hoisted(() => ({ + mockCompleteShopifyOAuthConnection: vi.fn(), + mockGetSession: vi.fn(), + mockRequireConfiguredOAuthClient: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/oauth/shopify', () => ({ + completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection, +})) + +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' +import { GET } from '@/app/api/auth/oauth2/callback/shopify/route' + +const CLIENT_SECRET = 'shopify-client-secret' +const SHOP_DOMAIN = 'example.myshopify.com' + +function callbackRequest(state: string) { + const searchParams = new URLSearchParams({ + code: 'authorization-code', + shop: SHOP_DOMAIN, + state, + }) + const message = [...searchParams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&') + searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET)) + + return createMockRequest( + 'GET', + undefined, + { + cookie: + 'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected', + }, + `https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}` + ) +} + +describe('Shopify OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockRequireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client-id', + SHOPIFY_CLIENT_SECRET: CLIENT_SECRET, + }, + }) + mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + }) + + it('completes the credential draft carried by signed state instead of a shared cookie', async () => { + const state = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-from-state', + clientSecret: CLIENT_SECRET, + }) + + const response = await GET(callbackRequest(state)) + + expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({ + accessToken: 'shopify-token', + shopDomain: SHOP_DOMAIN, + scope: 'read_products', + userId: 'user-1', + draftId: 'draft-from-state', + signal: expect.any(AbortSignal), + }) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 2292a76a9a6..bf2907856d2 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' +import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' +function clearShopifyOAuthCookies(response: NextResponse): NextResponse { + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') + response.cookies.delete('shopify_pending_token') + response.cookies.delete('shopify_pending_shop') + response.cookies.delete('shopify_pending_scope') + response.cookies.delete('shopify_return_url') + return response +} + /** * Validates the HMAC signature from Shopify to ensure the request is authentic * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens @@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { shop: searchParams.get('shop') || undefined, }) - const storedState = request.cookies.get('shopify_oauth_state')?.value - const storedShop = request.cookies.get('shopify_shop_domain')?.value - const { values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') @@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) } - if (!state || state !== storedState) { - logger.error('State mismatch in Shopify OAuth callback') + if (!state) { + logger.error('Missing state in Shopify OAuth callback') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) } @@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) } - const shopDomain = shop || storedShop + const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) @@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } + const { draftId } = parseShopifyOAuthState({ + state, + userId: session.user.id, + shopDomain, + clientSecret, + }) + const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, { method: 'POST', headers: { @@ -127,44 +145,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) } - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`) - - const response = NextResponse.redirect(storeUrl) - - response.cookies.set('shopify_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_shop', shopDomain, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - response.cookies.delete('shopify_oauth_state') - response.cookies.delete('shopify_shop_domain') + const returnUrlCookie = request.cookies.get('shopify_return_url')?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('shopify_connected', 'true') - return response + return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl)) } catch (error) { logger.error('Error in Shopify OAuth callback:', error) const errorCode = error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'shopify_config_error' : 'shopify_callback_error' - return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + return clearShopifyOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index ca58ab7c8f4..d3c84d68883 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { shopifyShopDomainSchema, @@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/lib/oauth/credential-service' -import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' const logger = createLogger('ShopifyStore') @@ -48,85 +43,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch( - `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, - { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - } - ) - - if (!shopResponse.ok) { - const errorText = await shopResponse.text() - logger.error('Invalid Shopify token', { - status: shopResponse.status, - error: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`) - } - - const shopData = await shopResponse.json() - const shopInfo = shopData.shop - const stableAccountId = shopInfo.id?.toString() || shopDomain - - const existing = await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - }) - - const now = new Date() - - const accountData = { - accessToken: accessToken, - accountId: stableAccountId, - scope: scope || '', - updatedAt: now, - idToken: shopDomain, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing Shopify account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `shopify_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'shopify', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'Shopify', identifier: shopDomain } - ) - } - - const persisted = - existing ?? - (await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - })) - - if (!persisted) { - throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) - } - await processCredentialDraft({ - draftId, + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, + draftId, + signal: request.signal, }) const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 63475a0cf76..9ef6909e581 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { shopifyAuthorizeQuerySchema, @@ -10,6 +9,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') @@ -26,7 +26,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const { - values: { SHOPIFY_CLIENT_ID: clientId }, + values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') const query = shopifyAuthorizeQuerySchema.parse({ @@ -175,7 +175,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` - const state = generateId() + const state = createShopifyOAuthState({ + userId: session.user.id, + shopDomain: cleanShop, + draftId, + clientSecret, + }) const oauthUrl = `https://${cleanShop}/admin/oauth/authorize?` + @@ -195,33 +200,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = NextResponse.redirect(oauthUrl) - response.cookies.set('shopify_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('shopify_shop_domain', cleanShop, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - if (draftId) { - response.cookies.set('shopify_credential_draft_id', draftId, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } else { - response.cookies.delete('shopify_credential_draft_id') - } + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set('shopify_return_url', returnUrl, { @@ -231,6 +212,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: 60 * 10, path: '/', }) + } else { + response.cookies.delete('shopify_return_url') } return response diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..28a3a15595f --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ draftId: 'draft-1' }) + expect(parse(second)).toEqual({ draftId: 'draft-2' }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow( + 'Shopify OAuth state is expired' + ) + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..23d5f29d761 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,102 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 +const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return decoded.draftId ? { draftId: decoded.draftId } : {} +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} From 036bfa04e4e83521be8092ca654e41b2217d29c4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 11:51:00 -0700 Subject: [PATCH 10/18] fix(credentials): align custom oauth reconnects --- .../auth/instagram/authorize/route.test.ts | 93 +++++++++++++++++++ .../app/api/auth/instagram/authorize/route.ts | 2 +- .../application/provider-catalog.test.ts | 2 +- .../application/provider-catalog.ts | 2 +- 4 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/api/auth/instagram/authorize/route.test.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts new file mode 100644 index 00000000000..7c5eb98a75a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), + createConnectDraft: vi.fn(), + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createConnectDraft, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['instagram_business_basic'], +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + +import { GET } from '@/app/api/auth/instagram/authorize/route' + +describe('Instagram authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, + }) + mocks.checkWorkspaceAccess.mockResolvedValue({ canWrite: true }) + mocks.createConnectDraft.mockResolvedValue({ id: 'draft-created' }) + }) + + it('preserves an exact credential draft when workspaceId is also supplied', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-exact' + ) + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.createConnectDraft).not.toHaveBeenCalled() + }) + + it('creates a credential draft for a legacy workspace-only launch', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-created' + ) + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(mocks.createConnectDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'instagram', + }) + }) +}) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 7d2c0270796..84b0154e706 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -38,7 +38,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { returnUrl, workspaceId, draftId } = parsed.data.query let credentialDraftId = draftId - if (workspaceId) { + if (workspaceId && !draftId) { const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.canWrite) { return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts index 08a452dd5c2..f485a80465d 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.test.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -144,7 +144,7 @@ describe('listCredentialProviderCatalog', () => { description: 'Connect Trello.', providerFamily: 'trello', available: false, - supportsReconnect: false, + supportsReconnect: true, authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], }, { diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 90e8d49d500..7dcdc507dbf 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -280,7 +280,7 @@ export async function listCredentialProviderCatalog( description: service.description, providerFamily: service.baseProvider, available: visibility.isOAuthServiceVisible(service), - supportsReconnect: !['trello', 'shopify'].includes(service.providerId), + supportsReconnect: true, authorizationOptions, } }) From 981f7577a49d13dc752319624c454d93c89d8230 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 13:18:15 -0700 Subject: [PATCH 11/18] fix(credentials): centralize application authorization --- apps/sim/app/api/auth/accounts/route.ts | 82 +-- .../api/auth/oauth/connections/route.test.ts | 6 +- .../app/api/auth/oauth/connections/route.ts | 156 +---- .../api/auth/oauth/disconnect/route.test.ts | 6 +- .../app/api/auth/oauth/disconnect/route.ts | 148 +---- .../api/auth/oauth2/authorize/route.test.ts | 555 +++++------------- .../app/api/auth/oauth2/authorize/route.ts | 91 +-- .../app/api/credentials/[id]/members/route.ts | 452 ++------------ apps/sim/app/api/credentials/[id]/route.ts | 236 ++------ apps/sim/app/api/credentials/draft/route.ts | 114 +--- .../app/api/credentials/memberships/route.ts | 163 ++--- apps/sim/app/api/credentials/route.test.ts | 57 ++ apps/sim/app/api/credentials/route.ts | 328 ++--------- apps/sim/lib/api/contracts/credentials.ts | 42 +- .../server/routes/internal-json-route.test.ts | 58 ++ .../api/server/routes/internal-json-route.ts | 15 +- .../execute-credential-use-case.ts | 14 + .../manage-application-use-cases.test.ts | 52 ++ .../handlers/management/manage-credential.ts | 132 ++--- .../lib/copilot/tools/handlers/oauth.test.ts | 352 +++-------- apps/sim/lib/copilot/tools/handlers/oauth.ts | 200 +------ .../sim/lib/credentials/api/route-policies.ts | 23 + .../credentials/application/authorization.ts | 12 + .../authorized-credential-use-case.test.ts | 105 ++++ .../authorized-credential-use-case.ts | 26 +- .../application/authorized-user-use-case.ts | 73 +++ .../application/connection-target.ts | 13 +- .../create-credential-connection.test.ts | 5 +- .../create-credential-connection.ts | 27 +- .../application/credential-context.ts | 32 + .../application/credential-crud.ts | 239 ++++++++ .../application/credential-members.ts | 148 +++++ .../delete-many-credentials.test.ts | 124 ++++ .../application/delete-many-credentials.ts | 114 ++++ .../list-credential-providers.test.ts | 8 +- .../application/list-credential-providers.ts | 3 +- .../list-workspace-credentials.test.ts | 14 +- .../credentials/application/oauth-accounts.ts | 98 ++++ .../application/operations.test.ts | 9 +- .../lib/credentials/application/operations.ts | 142 ++++- .../prepare-credential-connection.test.ts | 118 ++++ .../prepare-credential-connection.ts | 109 ++++ .../credentials/application/presentation.ts | 29 + .../application/save-credential-draft.test.ts | 109 ++++ .../application/save-credential-draft.ts | 67 +++ .../application/service-account.ts | 88 ++- apps/sim/lib/credentials/connect-draft.ts | 16 +- apps/sim/lib/credentials/members.ts | 265 +++++++++ apps/sim/lib/credentials/oauth-accounts.ts | 135 +++++ .../orchestration/credential-create.ts | 2 +- .../lib/credentials/orchestration/index.ts | 366 ++++++------ apps/sim/lib/credentials/queries.ts | 6 + 52 files changed, 3118 insertions(+), 2666 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-credential-use-case.ts create mode 100644 apps/sim/lib/credentials/api/route-policies.ts create mode 100644 apps/sim/lib/credentials/application/authorization.ts create mode 100644 apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts create mode 100644 apps/sim/lib/credentials/application/authorized-user-use-case.ts create mode 100644 apps/sim/lib/credentials/application/credential-context.ts create mode 100644 apps/sim/lib/credentials/application/credential-crud.ts create mode 100644 apps/sim/lib/credentials/application/credential-members.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.test.ts create mode 100644 apps/sim/lib/credentials/application/delete-many-credentials.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.test.ts create mode 100644 apps/sim/lib/credentials/application/prepare-credential-connection.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.test.ts create mode 100644 apps/sim/lib/credentials/application/save-credential-draft.ts create mode 100644 apps/sim/lib/credentials/members.ts create mode 100644 apps/sim/lib/credentials/oauth-accounts.ts diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 016384aa9c7..fd95740bf08 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -1,61 +1,23 @@ -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('AuthAccountsAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const { provider } = connectedAccountsQuerySchema.parse({ - provider: searchParams.get('provider') || undefined, - }) - - const whereConditions = [eq(account.userId, session.user.id)] - - if (provider) { - whereConditions.push(eq(account.providerId, provider)) - } - - const accounts = await db - .select({ - id: account.id, - accountId: account.accountId, - providerId: account.providerId, - credentialDisplayName: credential.displayName, - }) - .from(account) - .leftJoin(credential, eq(credential.accountId, account.id)) - .where(and(...whereConditions)) - .orderBy(desc(account.updatedAt)) - - const seen = new Map() - for (const acc of accounts) { - if (!seen.has(acc.id)) { - seen.set(acc.id, acc) - } - } - - const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({ - id: acc.id, - accountId: acc.accountId, - providerId: acc.providerId, - displayName: acc.credentialDisplayName || acc.accountId || acc.providerId, - })) - - return NextResponse.json({ accounts: accountsWithDisplayName }) - } catch (error) { - logger.error('Failed to fetch accounts', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listConnectedAccountsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { listConnectedAccountsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listConnectedAccountsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listConnectedAccounts, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listConnectedAccountsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 593079aa20c..80db8ab7a39 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -49,6 +49,7 @@ describe('OAuth Connections API Route', () => { it('should return connections successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ @@ -105,12 +106,13 @@ describe('OAuth Connections API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle user with no connections', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockResolvedValueOnce([]) @@ -128,6 +130,7 @@ describe('OAuth Connections API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) @@ -144,6 +147,7 @@ describe('OAuth Connections API Route', () => { it('should decode ID token for display name', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 9af427f9c17..92813d0638c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -1,139 +1,19 @@ -import { account, db, user } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { decodeJwt } from 'jose' -import { type NextRequest, NextResponse } from 'next/server' -import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { OAuthProvider } from '@/lib/oauth' -import { parseProvider } from '@/lib/oauth' - -const logger = createLogger('OAuthConnectionsAPI') - -interface GoogleIdToken { - email?: string - sub?: string - name?: string -} - -/** - * Get all OAuth connections for the current user - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - // Get the session - const session = await getSession() - - // Check if the user is authenticated - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - // Get all accounts for this user - const accounts = await db.select().from(account).where(eq(account.userId, session.user.id)) - - // Get the user's email for fallback - const userRecord = await db - .select({ email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - - // Process accounts to determine connections - const connections: OAuthConnection[] = [] - - for (const acc of accounts) { - const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) - const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] - - if (baseProvider) { - // Try multiple methods to get a user-friendly display name - let displayName = '' - - // Method 1: Try to extract email from ID token (works for Google, etc.) - if (acc.idToken) { - try { - const decoded = decodeJwt(acc.idToken) - if (decoded.email) { - displayName = decoded.email - } else if (decoded.name) { - displayName = decoded.name - } - } catch (_error) { - logger.warn(`[${requestId}] Error decoding ID token`, { - accountId: acc.id, - }) - } - } - - // Method 2: For GitHub, the accountId might be the username - if (!displayName && baseProvider === 'github') { - displayName = `${acc.accountId} (GitHub)` - } - - // Method 3: Use the user's email from our database - if (!displayName && userEmail) { - displayName = userEmail - } - - // Fallback: Use accountId with provider type as context - if (!displayName) { - displayName = `${acc.accountId} (${baseProvider})` - } - - // Create a unique connection key that includes the full provider ID - const connectionKey = acc.providerId - - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } - } - } - - return NextResponse.json({ connections }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching OAuth connections`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { listOAuthConnectionsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listOAuthConnectionsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listOAuthConnections, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: () => ({}), + useCase: listOAuthConnectionsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 757ea76c9df..e1dd3aa2eec 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -26,6 +26,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect provider successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -42,6 +43,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect specific provider ID successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -67,12 +69,13 @@ describe('OAuth Disconnect API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle missing provider', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', {}) @@ -87,6 +90,7 @@ describe('OAuth Disconnect API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index c3c145e60e7..d53f89128b7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,132 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, like, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteCredential } from '@/lib/credentials/deletion' -import { providerIdsForService } from '@/lib/oauth/utils' -import { captureServerEvent } from '@/lib/posthog/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' export const dynamic = 'force-dynamic' -const logger = createLogger('OAuthDisconnectAPI') - -/** - * Disconnect an OAuth provider for the current user - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const parsed = await parseRequest( - disconnectOAuthContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Validation failed') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { provider, providerId, accountId } = parsed.data.body - - logger.info(`[${requestId}] Processing OAuth disconnect request`, { - provider, - hasProviderId: !!providerId, - }) - - // Delete credentials before their accounts so deleteCredential can clear - // stored references first. Otherwise FK CASCADE would orphan them silently. - const accountFilter = accountId - ? and(eq(account.userId, session.user.id), eq(account.id, accountId)) - : providerId - ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) - : and( - eq(account.userId, session.user.id), - or( - // The prefix sweep already caught `{base}-{feature}` ids by - // accident; an alternate authorization server shares that shape, - // so name it explicitly rather than relying on the accident. - inArray(account.providerId, providerIdsForService(provider)), - like(account.providerId, `${provider}-%`) - ) - ) - - const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) - - const targetAccountIds = targetAccounts.map((a) => a.id) - - if (targetAccountIds.length > 0) { - const credentialsToDelete = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - providerId: credential.providerId, - }) - .from(credential) - .where(inArray(credential.accountId, targetAccountIds)) - - for (const cred of credentialsToDelete) { - await deleteCredential({ - credentialId: cred.id, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - reason: 'oauth_disconnect', - request, - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'oauth', - provider_id: cred.providerId ?? providerId ?? provider, - workspace_id: cred.workspaceId, - }, - { groups: { workspace: cred.workspaceId } } - ) - } - - await db.delete(account).where(inArray(account.id, targetAccountIds)) - } - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.OAUTH_DISCONNECTED, - resourceType: AuditResourceType.OAUTH, - resourceId: providerId ?? provider, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: provider, - description: `Disconnected OAuth provider: ${provider}`, - metadata: { provider, providerId }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: disconnectOAuthContract, + auth: internalSessionAuth, + operation: credentialUserOperations.disconnectOAuth, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: disconnectOAuthUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index a39d6f8f0a2..c4a275a840d 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,455 +1,210 @@ /** * @vitest-environment node */ -import { - createMockRequest, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - resetEnvMock, - schemaMock, - setEnv, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockOAuth2LinkAccount, - mockCheckWorkspaceAccess, - mockGetCredentialActorContext, - mockLaunchCredentialConnection, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockOAuth2LinkAccount: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockLaunchCredentialConnection: vi.fn(), +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + linkAccount: vi.fn(), + getBaseUrl: vi.fn(), + requireClient: vi.fn(), + createConnection: vi.fn(), + launchConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ - auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, - getSession: mockGetSession, + getSession: mocks.getSession, + auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/utils/urls', () => ({ + SITE_URL: 'https://www.sim.ai', + getBaseUrl: mocks.getBaseUrl, })) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireClient, + wireServerFallback: () => ({ + configured: false, + providerIds: [], + providers: [], + execute: vi.fn(), + }), +})) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.createConnection, + }, })) - vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ launchCredentialConnection: { operation: { id: 'credentials.connections.launch' }, - execute: mockLaunchCredentialConnection, + execute: mocks.launchConnection, }, })) -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), - // Real implementation: a credential id matches its service's OAuth id, an - // alternate authorization server, or the family's service-account id. - credentialProviderMatchesService: ( - credentialProviderId: string, - service: { - providerId: string - serviceAccountProviderId?: string - additionalProviderIds?: readonly string[] - } - ) => - service.providerId === credentialProviderId || - service.serviceAccountProviderId === credentialProviderId || - (service.additionalProviderIds?.includes(credentialProviderId) ?? false), -})) - import { GET } from '@/app/api/auth/oauth2/authorize/route' const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' -const LINK_URL = 'https://provider.example/authorize?state=abc' +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -function authorizeRequest(query: Record) { - const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value) - } +function request(query: Record) { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value) return createMockRequest('GET', undefined, {}, url.toString()) } -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - displayName: 'Work Gmail', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } +function linkResponse(url = 'https://provider.example/authorize') { + return new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) } describe('OAuth2 authorize route', () => { - afterAll(() => { - resetEnvMock() - }) - beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - setEnv({ - NEXT_PUBLIC_APP_URL: BASE_URL, - GOOGLE_CLIENT_ID: 'google-client', - GOOGLE_CLIENT_SECRET: 'google-secret', - }) - mockGetSession.mockResolvedValue({ - user: { id: USER_ID }, + mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, session: { id: 'session-1' }, }) - queueTableRows(schemaMock.user, [{ name: 'Test User' }]) - dbChainMockFns.onConflictDoUpdate.mockImplementation(() => ({ - returning: vi - .fn() - .mockResolvedValue([{ id: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z') }]), - })) - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - mockOAuth2LinkAccount.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ url: LINK_URL }), - headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, - }) + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date('2026-08-14T12:00:00.000Z'), + authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`, + }) + mocks.launchConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: null, + }, + }) + mocks.linkAccount.mockResolvedValue(linkResponse()) }) - describe('draft-bound connection', () => { - it('resolves the exact user-bound draft before starting OAuth', async () => { - mockLaunchCredentialConnection.mockResolvedValue({ - draft: { - id: 'draft-1', - userId: USER_ID, - workspaceId: WORKSPACE_ID, - providerId: 'google-email', - displayName: "Test User's Gmail", - description: null, - credentialId: null, - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - createdAt: new Date('2026-08-12T20:00:00.000Z'), - }, - }) - const request = authorizeRequest({ draftId: 'draft-1' }) - - const response = await GET(request) + it('creates a canonical application draft for a legacy connect URL', async () => { + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockLaunchCredentialConnection).toHaveBeenCalledWith({ - principal: { kind: 'session', userId: USER_ID, sessionId: 'session-1' }, - input: { draftId: 'draft-1' }, - request, + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, }) - expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - providerId: 'google-email', - callbackURL: `${BASE_URL}/oauth/credential-connected?result=connected&credentialDraftId=draft-1`, - errorCallbackURL: `${BASE_URL}/oauth/credential-connected?result=failed`, - }, - }) - ) - }) - - it('hands custom providers to their authenticated browser flow', async () => { - setEnv({ TRELLO_API_KEY: 'trello-key' }) - mockLaunchCredentialConnection.mockResolvedValue({ - draft: { - id: 'draft-1', - userId: USER_ID, - workspaceId: WORKSPACE_ID, - providerId: 'trello', - displayName: "Test User's Trello", - description: null, - credentialId: null, - expiresAt: new Date('2026-08-12T20:15:00.000Z'), - createdAt: new Date('2026-08-12T20:00:00.000Z'), - }, - }) - - const response = await GET(authorizeRequest({ draftId: 'draft-1' })) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/trello/authorize?returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected&draftId=draft-1` - ) - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - }) - - describe('plain connect (no credentialId)', () => { - it('creates a draft with credentialId null and redirects to the provider', async () => { - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER_ID, - workspaceId: WORKSPACE_ID, + ) + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ providerId: 'google-email', - credentialId: null, - }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ setWhere: expect.anything() }) - ) - expect(mockOAuth2LinkAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ - callbackURL: `${BASE_URL}/workspace?credentialDraftId=draft-1`, - }), - }) - ) - }) - - it('numbers the draft display name when the default collides with an existing credential', async () => { - dbChainMockFns.where - .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) - .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) - - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: "Justin's Gmail 2" }) - ) - }) - - it('does not overwrite a reconnect intent when refreshing a plain connect', async () => { - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - const [{ set, setWhere }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).not.toHaveProperty('credentialId') - expect(setWhere).toBeDefined() - }) - - it('rejects an OAuth client that is not configured for the deployment', async () => { - setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('redirects to login when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toContain('/login') - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects without workspace write access', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, + callbackURL: expect.stringContaining('credentialDraftId=draft-1'), + }), }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + ) }) - describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft and guards conflict refreshes by intent', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).toHaveBeenCalledWith( - CREDENTIAL_ID, - USER_ID, - expect.objectContaining({ workspaceAccess: expect.anything() }) - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: CREDENTIAL_ID }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ setWhere: expect.anything() }) - ) - }) - - it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) - ) + it('launches an exact draft without creating another one', async () => { + const response = await GET(request({ draftId: 'draft-1' })) - await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.launchConnection).toHaveBeenCalledWith( + expect.objectContaining({ input: { draftId: 'draft-1' } }) + ) + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: 'Renamed By User' }) - ) + it('passes reconnect provider assertions through the application use case', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('binds custom-provider reconnects to the exact draft', async () => { - setEnv({ - TRELLO_API_KEY: 'trello-key', - SHOPIFY_CLIENT_ID: 'shopify-client', - SHOPIFY_CLIENT_SECRET: 'shopify-secret', + await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', }) - for (const providerId of ['trello', 'shopify']) { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { providerId } }) - ) - const response = await GET( - authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/api/auth/${providerId}/authorize?returnUrl=https%3A%2F%2Fsim.test%2Fworkspace&draftId=draft-1` - ) - } - expect(mockGetCredentialActorContext).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.values).toHaveBeenCalledTimes(2) - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the caller is not a credential admin and writes no draft', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('rejects when the credential belongs to a different workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects when the credential does not exist', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, + credentialId: 'credential-1', + assertedProviderId: 'google-email', + }, }) + ) + }) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: 'cred-missing', - }) - ) + it('maps a provider mismatch without exposing the credential', async () => { + mocks.createConnection.mockRejectedValue( + new CredentialConnectionProviderMismatchError('google-email', 'slack') + ) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + const response = await GET( + request({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - it('rejects a non-oauth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + }) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + it('maps credential and workspace authorization failures separately', async () => { + mocks.createConnection.mockRejectedValueOnce( + new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + ) + const credentialResponse = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + mocks.createConnection.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Write permission required') + ) + const workspaceResponse = await GET( + request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(credentialResponse.headers.get('location')).toContain('credential_access_denied') + expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() + it('routes custom providers through the exact application draft', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'trello', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the query providerId does not match the credential provider', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) + const response = await GET(request({ providerId: 'trello', workspaceId: WORKSPACE_ID })) + const location = new URL(response.headers.get('location') ?? '') - const response = await GET( - authorizeRequest({ - providerId: 'slack', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + expect(location.pathname).toBe('/api/auth/trello/authorize') + expect(location.searchParams.get('draftId')).toBe('draft-1') }) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 98543d53099..61ce6823600 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -3,15 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' -import { createConnectDraft } from '@/lib/credentials/connect-draft' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') @@ -30,6 +30,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(loginUrl.toString()) } const userId = session.user.id + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal = { kind: 'session' as const, userId, sessionId } const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response @@ -40,14 +43,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let connectionDraftId: string | undefined if (draftId) { try { - const sessionId = session.session?.id - if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { draft } = await launchCredentialConnection.execute({ - principal: { - kind: 'session', - userId, - sessionId, - }, + principal, input: { draftId }, request, }) @@ -76,66 +73,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : `${baseUrl}/workspace` try { - let reconnectDisplayName: string | undefined if (!fromConnectionDraft) { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, + try { + const connection = await createCredentialConnection.execute({ + principal, + input: credentialId + ? { workspaceId, credentialId, assertedProviderId: providerId } + : { workspaceId, providerId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) - } - - if (credentialId) { - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) + providerId = connection.providerId + workspaceId = connection.workspaceId + credentialId = connection.credentialId + connectionDraftId = connection.draftId + } catch (error) { + if (error instanceof CredentialConnectionProviderMismatchError) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + if (credentialId && error instanceof ForbiddenOperationError) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.redirect( + `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + ) } - reconnectDisplayName = actor.credential.displayName + if (error instanceof OrchestrationError && error.code === 'forbidden') { + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + } + throw error } } requireConfiguredOAuthClient(providerId) - if (!draftId) { - const draft = await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) - connectionDraftId = draft.id - } - if (!connectionDraftId) { throw new Error('OAuth authorization is missing its credential draft id') } diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 72132ee56d0..2e47b6b4354 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -1,406 +1,64 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credential, credentialMember, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { + listWorkspaceCredentialMembersContract, + removeWorkspaceCredentialMemberContract, upsertWorkspaceCredentialMemberContract, - type WorkspaceCredentialMember, } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' -import { captureServerEvent } from '@/lib/posthog/server' import { - getUserEntityPermissions, - getUsersWithPermissions, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialMembersAPI') - -interface RouteContext { - params: Promise<{ id: string }> -} - -async function requireCredentialAdmin(credentialId: string, userId: string) { - const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred) return null - - const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) - if (perm === null) return null - - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - const isAdmin = deriveCredentialAdmin({ - credentialType: cred.type, - memberRole: membership?.status === 'active' ? membership.role : null, - workspaceCanAdmin: perm === 'admin', - }) - - if (!isAdmin) { - return null - } - return { credentialType: cred.type, workspaceId: cred.workspaceId } -} - -export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const [cred] = await db - .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const callerPerm = await getUserEntityPermissions( - session.user.id, - 'workspace', - cred.workspaceId - ) - if (callerPerm === null) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const explicitMembers = await db - .select({ - id: credentialMember.id, - userId: credentialMember.userId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - userName: user.name, - userEmail: user.email, - }) - .from(credentialMember) - .innerJoin(user, eq(credentialMember.userId, user.id)) - .where(eq(credentialMember.credentialId, credentialId)) - - const byUser = new Map( - explicitMembers.map((m) => [ - m.userId, - { - id: m.id, - userId: m.userId, - role: m.role, - status: m.status, - joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, - userName: m.userName, - userEmail: m.userEmail, - roleSource: 'explicit' as const, - }, - ]) - ) - - if (isSharedCredentialType(cred.type)) { - const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) - for (const wsMember of workspaceMembers) { - if (wsMember.permissionType !== 'admin') continue - const existing = byUser.get(wsMember.userId) - if (existing) { - existing.role = 'admin' - existing.status = 'active' - existing.roleSource = 'workspace-admin' - } else { - byUser.set(wsMember.userId, { - id: `workspace-admin-${wsMember.userId}`, - userId: wsMember.userId, - role: 'admin', - status: 'active', - joinedAt: null, - userName: wsMember.name, - userEmail: wsMember.email, - roleSource: 'workspace-admin', - }) - } - } - } - - const members = Array.from(byUser.values()) - - return NextResponse.json({ members }) - } catch (error) { - logger.error('Failed to fetch credential members', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + listCredentialMembersUseCase, + removeCredentialMemberUseCase, + upsertCredentialMemberUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialMembersContract, + auth: internalSessionAuth, + operation: credentialOperations.listMembers, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: listCredentialMembersUseCase, + present: ({ members }) => ({ + members: members.map((member) => ({ + ...member, + joinedAt: member.joinedAt?.toISOString() ?? null, + })), + }), }) -export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - if (!isSharedCredentialType(admin.credentialType)) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'env_personal-cannot-be-shared', - }) - return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 }) - } - - const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId, role } = parsed.data.body - - const targetWorkspacePerm = await getUserEntityPermissions( - userId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin' && role !== 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, - { status: 400 } - ) - } - - const now = new Date() - - const [existing] = await db - .select({ id: credentialMember.id, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - if (existing) { - const result = await db.transaction(async (tx) => { - const [current] = await tx - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where(eq(credentialMember.id, existing.id)) - .limit(1) - .for('update') - if ( - !isSharedCredentialType(admin.credentialType) && - current?.role === 'admin' && - current?.status === 'active' && - role !== 'admin' - ) { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - if (activeAdmins.length <= 1) return { ok: false as const } - } - await tx - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) - return { ok: true as const, fromRole: current?.role } - }) - if (!result.ok) { - return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) - } - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Changed credential member role to "${role}"`, - metadata: { targetUserId: userId, fromRole: result.fromRole, toRole: role }, - request, - }) - - return NextResponse.json({ success: true }) - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId, - role, - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - captureServerEvent(session.user.id, 'credential_shared', { - credential_type: admin.credentialType, - role, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ADDED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Shared credential with member as "${role}"`, - metadata: { targetUserId: userId, role }, - request, - }) - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (error) { - logger.error('Failed to add credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: upsertWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.upsertMember, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: upsertCredentialMemberUseCase, + present: () => ({ success: true as const }), + statusForResult: ({ created }) => (created ? 201 : 200), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - const targetUserId = new URL(request.url).searchParams.get('userId') - if (!targetUserId) { - return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) - } - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member removal denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - - const [target] = await db - .select({ - id: credentialMember.id, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, targetUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!target) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - if (isSharedCredentialType(admin.credentialType)) { - const targetWorkspacePerm = await getUserEntityPermissions( - targetUserId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be removed' }, - { status: 400 } - ) - } - } - - const revoked = await db.transaction(async (tx) => { - if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where(eq(credentialMember.id, target.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 }) - } - - captureServerEvent(session.user.id, 'credential_unshared', { - credential_type: admin.credentialType, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: 'Removed credential member', - metadata: { targetUserId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to remove credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: removeWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.removeMember, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, query }) => ({ credentialId: params.id, userId: query.userId }), + useCase: removeCredentialMemberUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 3ff1de37444..d99ad8382c4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,177 +1,63 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type CredentialActorContext, - canUseCredential, - getCredentialActorContext, -} from '@/lib/credentials/access' + deleteWorkspaceCredentialContract, + getWorkspaceCredentialContract, + updateWorkspaceCredentialContract, +} from '@/lib/api/contracts/credentials' import { - isProviderOutageCode, - performDeleteCredential, - performUpdateCredential, -} from '@/lib/credentials/orchestration' - -const logger = createLogger('CredentialByIdAPI') - -function formatCredentialResponse(access: CredentialActorContext) { - const cred = access.credential - if (!cred) return null - - return { - id: cred.id, - workspaceId: cred.workspaceId, - type: cred.type, - displayName: cred.displayName, - description: cred.description, - providerId: cred.providerId, - accountId: cred.accountId, - envKey: cred.envKey, - envOwnerUserId: cred.envOwnerUserId, - createdBy: cred.createdBy, - createdAt: cred.createdAt, - updatedAt: cred.updatedAt, - role: access.isAdmin ? 'admin' : (access.member?.role ?? null), - status: access.member?.status ?? (access.isAdmin ? 'active' : null), - } -} - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!canUseCredential(access)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to fetch credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const body = parsed.data.body - - const result = await performUpdateCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - displayName: body.displayName, - description: body.description, - serviceAccountJson: body.serviceAccountJson, - signingSecret: body.signingSecret, - botToken: body.botToken, - apiToken: body.apiToken, - domain: body.domain, - clientId: body.clientId, - clientSecret: body.clientSecret, - certificateId: body.certificateId, - orgId: body.orgId, - dataCenter: body.dataCenter, - authMethod: body.authMethod, - privateKey: body.privateKey, - username: body.username, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : // A provider outage during reconnect is infra, not a bad - // request — mirror the create route and runtime token route. - // Every provider family names its own outage code, so this - // asks the shared predicate rather than matching one literal. - isProviderOutageCode(result.providerErrorCode) - ? 502 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json( - { - error: result.error, - ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}), - }, - { status } - ) - } - - const access = await getCredentialActorContext(id, session.user.id) - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to update credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const result = await performDeleteCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to delete credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + getWorkspaceCredentialUseCase, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.read, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: getWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.update, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: updateWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.delete, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: deleteCredentialUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 2e693609438..545101f591c 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,95 +1,23 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialDraftAPI') - -const DRAFT_TTL_MS = 15 * 60 * 1000 - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createCredentialDraftContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body - const userId = session.user.id - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - if (credentialId) { - const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) { - return NextResponse.json( - { error: 'Admin access required on the target credential' }, - { status: 403 } - ) - } - } - - const now = new Date() - - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }, - }) - - logger.info('Credential draft saved', { - userId, - workspaceId, - providerId, - displayName, - credentialId: credentialId || null, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to save credential draft', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +export const POST = defineInternalJsonRoute({ + contract: createCredentialDraftContract, + auth: internalSessionAuth, + operation: credentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: saveCredentialDraft, }) diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 7e855d2caca..6ee05aa1de6 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,121 +1,48 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialMembershipsAPI') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialMember.id, - credentialId: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - providerId: credential.providerId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - }) - .from(credentialMember) - .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where(eq(credentialMember.userId, session.user.id)) - - return NextResponse.json({ memberships }, { status: 200 }) - } catch (error) { - logger.error('Failed to list credential memberships', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + leaveCredentialMembershipContract, + listCredentialMembershipsContract, +} from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + leaveCredentialMembershipUseCase, + listCredentialMembershipsUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listCredentialMembershipsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listMemberships, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: () => ({}), + useCase: listCredentialMembershipsUseCase, + present: ({ memberships }) => ({ + memberships: memberships.map((membership) => ({ + ...membership, + joinedAt: membership.joinedAt?.toISOString() ?? null, + })), + }), }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parseResult = leaveCredentialQuerySchema.safeParse({ - credentialId: new URL(request.url).searchParams.get('credentialId'), - }) - if (!parseResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { credentialId } = parseResult.data - const [membership] = await db - .select() - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) - } - - if (membership.status !== 'active') { - return NextResponse.json({ success: true }, { status: 200 }) - } - - const revoked = await db.transaction(async (tx) => { - if (membership.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialMember.id, membership.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json( - { error: 'Cannot leave credential as the last active admin' }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to leave credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: leaveCredentialMembershipContract, + auth: internalSessionAuth, + operation: credentialUserOperations.leaveMembership, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: leaveCredentialMembershipUseCase, }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 3a105b71e57..c9580a98073 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -18,11 +18,17 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv const { mockCheckWorkspaceAccess, + mockGetCredentialActorContext, mockGetCredentialCreationWorkspaceContext, + mockLoadWorkspace, + mockResolveWorkspacePermission, mockVerifyAndBuildServiceAccountSecret, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), mockGetCredentialCreationWorkspaceContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolveWorkspacePermission: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), })) @@ -33,6 +39,24 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolveWorkspacePermission, +})) + +vi.mock('@/lib/credentials/access', () => ({ + canUseCredential: (access: { member: unknown; isAdmin: boolean; hasWorkspaceAccess: boolean }) => + access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), + getCredentialActorContext: mockGetCredentialActorContext, + isSharedCredentialType: (type: string) => type !== 'env_personal', + SHARED_CREDENTIAL_TYPES: ['oauth', 'env_workspace', 'service_account'], +})) + vi.mock('@/lib/credentials/environment', () => ({ getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext, })) @@ -57,6 +81,12 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_CONTEXT = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', +} describe('GET /api/credentials', () => { beforeEach(() => { @@ -64,7 +94,10 @@ describe('GET /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('read') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -120,7 +153,10 @@ describe('POST /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('write') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -132,6 +168,27 @@ describe('POST /api/credentials', () => { memberUserIds: ['user-1'], canWrite: true, }) + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Service account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }) }) describe('client-credential service accounts', () => { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 69ec1fb54e2..a653ff0a7d1 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,291 +1,51 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, - credentialsListGetQuerySchema, + listWorkspaceCredentialsContract, } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - performCreateCredential, - statusForCredentialOrchestrationError, -} from '@/lib/credentials/orchestration/credential-create' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialsAPI') - -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -/** - * `return await` is load-bearing, not redundant. Next 16.3.0's Turbopack - * optimizer models a bare `return ()` tail call as returning the - * promise object, then propagates that always-truthy fact through the caller's - * `await`. It concludes `if (existingCredential)` is always taken and — because - * every branch inside that block returns — deletes the entire create path from - * the emitted bundle, so a first-time create throws on `existingCredential.id`. - * Awaiting here makes the optimizer model the resolved value instead. - */ -async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) { - return await findExistingCredentialBySourceWith(db, params) -} - -async function findExistingCredentialBySourceTx( - tx: Parameters[0]>[0], - params: ExistingCredentialSourceParams -) { - return await findExistingCredentialBySourceWith(tx, params) -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { searchParams } = new URL(request.url) - const rawWorkspaceId = searchParams.get('workspaceId') - const rawType = searchParams.get('type') - const rawProviderId = searchParams.get('providerId') - const rawCredentialId = searchParams.get('credentialId') - const parseResult = credentialsListGetQuerySchema.safeParse({ - workspaceId: rawWorkspaceId?.trim(), - type: rawType?.trim() || undefined, - providerId: rawProviderId?.trim() || undefined, - credentialId: rawCredentialId?.trim() || undefined, - }) - - if (!parseResult.success) { - logger.warn(`[${requestId}] Invalid credential list request`, { - workspaceId: rawWorkspaceId, - type: rawType, - providerId: rawProviderId, - errors: parseResult.error.issues, - }) - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - - if (!workspaceAccess.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (lookupCredentialId) { - let [row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId))) - .limit(1) - - if (!row) { - ;[row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId) - ) - ) - .limit(1) - } - - return NextResponse.json({ credential: row ?? null }) - } - - if (!type || type === 'oauth') { - await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) - } - - const visible = await listVisibleWorkspaceCredentials({ - workspaceId, - userId: session.user.id, - workspaceAccess, - types: type ? [type] : undefined, - providerId, - }) - const credentials = visible.data.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error(`[${requestId}] Failed to list credentials`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + createWorkspaceCredential, + listInternalCredentials, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listInternal, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listInternalCredentials, + present: ({ credentials, credential }) => ({ + credentials: credentials.map((row) => toWorkspaceCredential(row)), + ...(credential !== undefined + ? { credential: credential ? toWorkspaceCredential(credential) : null } + : {}), + }), }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const result = await performCreateCredential({ - ...parsed.data.body, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success) { - logger.warn(`[${requestId}] Credential create rejected`, { - errorCode: result.errorCode, - providerErrorCode: result.providerErrorCode, - }) - const status = statusForCredentialOrchestrationError(result.errorCode, { - providerUnavailable: result.providerUnavailable, - }) - return NextResponse.json( - result.providerErrorCode - ? { code: result.providerErrorCode, error: result.error } - : { error: result.error }, - { status } - ) - } - - if (!result.credential) { - throw new Error('Credential creation succeeded without a credential') - } - - const responseBody = createWorkspaceCredentialContract.response.schema.parse({ - credential: { - ...result.credential, - createdAt: result.credential.createdAt.toISOString(), - updatedAt: result.credential.updatedAt.toISOString(), - }, - }) - - // An existing credential matched the source: an idempotent replay, not a create. - return NextResponse.json(responseBody, { status: result.created ? 201 : 200 }) +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: createWorkspaceCredential, + present: ({ credential, role, status }) => ({ + credential: { ...toWorkspaceCredential({ ...credential, role }), status }, + }), + statusForResult: ({ created }) => (created ? 201 : 200), }) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index e3c00abec1f..25a4189a61e 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -46,19 +46,13 @@ export const credentialsListQuerySchema = z.object({ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), type: workspaceCredentialTypeSchema.optional(), providerId: z.string().optional(), + credentialId: z.string().optional(), }) export const credentialIdParamsSchema = z.object({ id: z.string().min(1), }) -export const credentialsListGetQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - export const serviceAccountJsonSchema = z .string() .min(1, 'Service account JSON key is required') @@ -255,6 +249,18 @@ export const leaveCredentialQuerySchema = z.object({ credentialId: z.string().min(1), }) +export const credentialMembershipSchema = z.object({ + membershipId: z.string(), + credentialId: z.string(), + workspaceId: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + providerId: z.string().nullable(), + role: workspaceCredentialRoleSchema, + status: workspaceCredentialMemberStatusSchema, + joinedAt: z.string().nullable(), +}) + export const workspaceCredentialMemberSchema = z.object({ id: z.string(), userId: z.string(), @@ -321,6 +327,7 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ mode: 'json', schema: z.object({ credentials: z.array(workspaceCredentialSchema), + credential: workspaceCredentialSchema.nullable().optional(), }), }, }) @@ -379,6 +386,7 @@ export const createWorkspaceCredentialContract = defineRouteContract({ body: createCredentialBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ credential: workspaceCredentialSchema, }), @@ -417,6 +425,7 @@ export const upsertWorkspaceCredentialMemberContract = defineRouteContract({ body: upsertWorkspaceCredentialMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), member: workspaceCredentialMemberSchema.optional(), @@ -436,3 +445,22 @@ export const removeWorkspaceCredentialMemberContract = defineRouteContract({ }), }, }) + +export const listCredentialMembershipsContract = defineRouteContract({ + method: 'GET', + path: '/api/credentials/memberships', + response: { + mode: 'json', + schema: z.object({ memberships: z.array(credentialMembershipSchema) }), + }, +}) + +export const leaveCredentialMembershipContract = defineRouteContract({ + method: 'DELETE', + path: '/api/credentials/memberships', + query: leaveCredentialQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 53c0e054622..7f10e36b703 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -383,4 +383,62 @@ describe('defineInternalJsonRoute', () => { __privateMetadata: { value: 'ok' }, }) }) + + it('selects a declared success status from the application result', async () => { + const replayableContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: [200, 201], + }, + }) + const handler = defineInternalJsonRoute({ + contract: replayableContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'created', created: true } + }, + }, + present: ({ value }) => ({ value }), + statusForResult: ({ created }) => (created ? 201 : 200), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ value: 'created' }) + }) + + it('fails closed when the application selects an undeclared success status', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + statusForResult: () => 201, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) }) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index f9102d57217..8e362c35f66 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -230,6 +230,7 @@ type InternalJsonRouteOptions< params: Record }): void | Promise onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + statusForResult?(result: NoInfer): number responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -287,12 +288,6 @@ export function defineInternalJsonRoute< options.operation, options.useCase.operation ) - if (successStatuses.length !== 1) { - throw new Error( - `${options.contract.method} ${options.contract.path} internal JSON route requires one success status` - ) - } - const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -344,6 +339,12 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!successStatuses.includes(responseStatus)) { + throw new Error( + `Internal JSON route produced undeclared success status ${responseStatus}; expected ${successStatuses.join(', ')}` + ) + } const headers = options.responseHeaders?.({ principal, input, result }) const finalization = options.finalizeResponse ? await options.finalizeResponse({ @@ -357,7 +358,7 @@ export function defineInternalJsonRoute< return NextResponse.json( appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), { - status: successStatus, + status: responseStatus, headers: appendFinalizedHeaders(headers, finalization?.headers), } ) diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/copilot/application/execute-credential-use-case.ts new file mode 100644 index 00000000000..cbebe99402a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-credential-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ + domain: 'credential', + delegation: { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: credentialOperations, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index e7ea463273b..9b9ebb41da5 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -8,6 +8,7 @@ const { mocks, useCases } = vi.hoisted(() => ({ custom: vi.fn(), mcp: vi.fn(), skill: vi.fn(), + credential: vi.fn(), capture: vi.fn(), }, useCases: { @@ -23,6 +24,8 @@ const { mocks, useCases } = vi.hoisted(() => ({ deleteSkill: { operation: { id: 'skills.delete' } }, listSkill: { operation: { id: 'skills.list_available' } }, updateSkill: { operation: { id: 'skills.update' } }, + updateCredential: { operation: { id: 'credentials.update' } }, + deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, }, })) @@ -35,6 +38,9 @@ vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ executeCopilotSkillUseCase: mocks.skill, })) +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.credential, +})) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ deleteAvailableCustomToolUseCase: useCases.deleteCustom, listAvailableCustomToolsUseCase: useCases.listCustom, @@ -53,9 +59,16 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ listAvailableSkillsUseCase: useCases.listSkill, updateSkillUseCase: useCases.updateSkill, })) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + updateWorkspaceCredentialUseCase: useCases.updateCredential, +})) +vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ + deleteManyCredentialsUseCase: useCases.deleteManyCredentials, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' @@ -157,4 +170,43 @@ describe('Copilot management application boundaries', () => { } ) }) + + it('renames credentials through the shared credential use case', async () => { + mocks.credential.mockResolvedValue({ + credential: { id: 'credential-1', displayName: 'Renamed' }, + previousDisplayName: 'Original', + }) + + const result = await executeManageCredential( + { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { previousDisplayName: 'Original', displayName: 'Renamed' }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { + credentialId: 'credential-1', + displayName: 'Renamed', + }) + }) + + it('keeps best-effort batch deletion inside one semantic application command', async () => { + mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) + + const result = await executeManageCredential( + { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { deleted: ['credential-1'], failed: ['credential-2'] }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..07dc5d0bc44 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -1,84 +1,82 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' -export function executeManageCredential( +export async function executeManageCredential( rawParams: Record, context: ExecutionContext ): Promise { - const params = rawParams as { - operation: string - credentialId?: string - credentialIds?: string[] - displayName?: string + const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' + const credentialId = + typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined + const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined + const rawCredentialIds = rawParams.credentialIds + if ( + rawCredentialIds !== undefined && + (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) + ) { + return { success: false, error: 'credentialIds must be an array of strings' } } - const { operation, displayName } = params - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - - switch (operation) { - case 'rename': { - const credentialId = params.credentialId - if (!credentialId) return { success: false, error: 'credentialId is required for rename' } - if (!displayName) return { success: false, error: 'displayName is required for rename' } + const credentialIds = rawCredentialIds as string[] | undefined + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const result = await performUpdateCredential({ + try { + switch (operation) { + case 'rename': { + if (!credentialId) { + return { success: false, error: 'credentialId is required for rename' } + } + if (!displayName) { + return { success: false, error: 'displayName is required for rename' } + } + const result = await executeCopilotCredentialUseCase( + context, + updateWorkspaceCredentialUseCase, + { credentialId, - userId: context.userId, displayName, - allowedTypes: ['oauth'], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename credential' } - } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, } + ) + return { + success: true, + output: { + credentialId: result.credential.id, + previousDisplayName: result.previousDisplayName, + displayName: result.credential.displayName, + }, } - case 'delete': { - const ids: string[] = - params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) - if (ids.length === 0) - return { success: false, error: 'credentialId or credentialIds is required for delete' } - - const deleted: string[] = [] - const failed: string[] = [] - - for (const id of ids) { - const result = await performDeleteCredential({ - credentialId: id, - userId: context.userId, - allowedTypes: ['oauth'], - reason: 'copilot_delete', - }) - if (!result.success) { - failed.push(id) - continue - } - deleted.push(id) - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } - default: + } + case 'delete': { + const ids = credentialIds ?? (credentialId ? [credentialId] : []) + if (ids.length === 0) { return { success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + error: 'credentialId or credentialIds is required for delete', } + } + const result = await executeCopilotCredentialUseCase( + context, + deleteManyCredentialsUseCase, + { workspaceId, credentialIds: ids } + ) + return { + success: result.deleted.length > 0, + output: { deleted: result.deleted, failed: result.failed }, + } } - } catch (error) { - return { success: false, error: toError(error).message } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } } - })() + } catch (error) { + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to manage credential'), + } + } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 77ff5a9922d..f3926a3ed11 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -2,324 +2,114 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { - mockEnsureWorkspaceAccess, - mockGetCredentialActorContext, - mockIsOAuthServiceDeploymentAvailable, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true), - mockGetUserPermissionConfig: vi.fn(), +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getBaseUrl: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +const useCases = vi.hoisted(() => ({ + prepare: { operation: { id: 'credentials.connections.prepare' } }, })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.execute, })) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' }, - { serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' }, - { serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' }, - { serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' }, - { - serviceId: 'claude-platform', - providerId: 'claude-platform', - name: 'Claude Platform', - authType: 'service_account', - }, - ]), +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) +vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ + prepareCredentialConnection: useCases.prepare, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, +const context: ExecutionContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'write', } describe('executeOAuthGetAuthLink', () => { beforeEach(() => { vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects a provider whose OAuth client is not configured', async () => { - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not configured for this deployment') - }) - - it('rejects a provider disallowed for the workspace member', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not allowed for this workspace member') - }) - - it('does not treat service-account-only metadata as OAuth', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + mocks.getBaseUrl.mockReturnValue('https://sim.test') + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) + it('uses the credential application adapter for a new connection', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: undefined, }) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('workspaceId')).toBe('workspace-1') + expect(url.searchParams.has('credentialId')).toBe(false) + }) - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') + it('preserves the canonical credential ID for reconnect', async () => { + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', + credentialId: 'credential-1', }) - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail', credentialId: 'credential-1' }, + context + ) - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) + const output = result.output as { oauth_url: string; message: string } + expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') + expect(output.message).toContain('re-authorizes credential credential-1 in place') + }) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + it('returns application validation errors without exposing infrastructure failures', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) + const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + expect(result.success).toBe(false) + expect(result.error).toBe('Provider not found') }) -}) -describe('executeOAuthGetAuthLink service account rejection', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) + it('fails fast without trusted workspace context', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail' }, + { ...context, workspaceId: undefined } + ) + + expect(result).toEqual({ success: false, error: 'workspaceId is required' }) + expect(mocks.execute).not.toHaveBeenCalled() }) - /** - * Regression: a user asked for a "new custom bot", the agent correctly - * resolved that to `slack-custom-bot` and passed it here, and the fuzzy - * substring pass matched it to the Slack OAuth service — `slack-custom-bot` - * contains `slack`. The tool returned a personal-OAuth authorize URL and - * reported success, so the user connected their own account instead of a - * shared bot. Failing loudly is the point: a wrong link that looks right is - * worse than an error the agent can recover from. - */ - it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + it('rejects service-account providers before OAuth resolution', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service account') - expect(result.error).toContain('service_account credential tag') - const output = result.output as { setup_url?: string; oauth_url?: string; message: string } - // The rejection must not fall into the generic catch, which would attach a - // contradicting workspace oauth_url and a "connect manually" message — the - // agent would then surface a workspace link instead of the tag. - expect(output.setup_url).toBeUndefined() - expect(output.oauth_url).toBeUndefined() - expect(output.message).toContain('service_account credential tag') - expect(output.message).not.toContain('Connect manually') + expect(result.error).toContain('service account, not an OAuth provider') + expect(mocks.execute).not.toHaveBeenCalled() }) - it.each([ - 'notion-service-account', - 'salesforce-service-account', - 'google-service-account', - 'atlassian-service-account', - 'SLACK-CUSTOM-BOT', - // Readable forms must be normalized (spaces/underscores → hyphens) so they - // are caught too, not passed to the fuzzy OAuth resolver. - 'slack custom bot', - 'google service account', - 'notion_service_account', - ])('rejects %s', async (providerName) => { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('service_account credential tag') - }) + it('does not confuse integrations that also offer service accounts', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { - // `slack` and `notion` must keep working — the guard keys off the id being - // a service-account id, not off the integration having a service-account flow. - for (const providerName of ['slack', 'google-email']) { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(true) - expect((result.output as { oauth_url: string }).oauth_url).toContain( - '/api/auth/oauth2/authorize' - ) - } + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 5549efacd10..eb24cb86ab6 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,16 +1,9 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export async function executeOAuthGetAuthLink( rawParams: Record, @@ -38,33 +31,26 @@ export async function executeOAuthGetAuthLink( `value instead (e.g. "slack") — it opens the service account setup form in chat.` return { success: false, error: message, output: { message } } } + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } try { - if (!context.workspaceId || !context.userId) { - throw new Error('workspaceId and userId are required to generate an OAuth link') - } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) - const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null - const result = await generateOAuthLink( - context.workspaceId, - context.workflowId, - context.chatId, + const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { + workspaceId, providerName, - baseUrl, - allowedIntegrationTypes, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined - ) + credentialId, + }) + const callbackURL = context.workflowId + ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` + : context.chatId + ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` + : `${baseUrl}/workspace/${workspaceId}` + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', result.providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, @@ -72,23 +58,24 @@ export async function executeOAuthGetAuthLink( message: credentialId ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + oauth_url: authorizeUrl.toString(), + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, provider: result.serviceName, providerId: result.providerId, }, } } catch (err) { + const message = messageForCopilotApplicationError(err) const workspaceUrl = context.workspaceId ? `${baseUrl}/workspace/${context.workspaceId}` : `${baseUrl}/workspace` return { success: false, - error: toError(err).message, + error: message, output: { message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, oauth_url: workspaceUrl, - error: toError(err).message, + error: message, }, } } @@ -109,140 +96,3 @@ export async function executeOAuthRequestAccess( }, } } - -/** - * Resolves a human-friendly provider name to a providerId and returns a - * browser-initiated authorize URL the user opens to connect the service. - * - * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. - * That endpoint (not this server-side handler) creates the credential draft and - * calls Better Auth, so the draft's TTL starts at click and the signed `state` - * cookie is planted in the user's browser and the OAuth callback's state check - * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. - */ -async function generateOAuthLink( - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string, - allowedIntegrationTypes: ReadonlySet | null, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) { - throw new Error(`${serviceName} is not allowed for this workspace member`) - } - if (!isOAuthServiceDeploymentAvailable(providerId)) { - throw new Error(`${serviceName} OAuth is not configured for this deployment`) - } - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - if (providerId === 'trello') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'instagram') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Hand back a browser-initiated authorize URL rather than calling - // oAuth2LinkAccount here. Generating the link server-side would set Better - // Auth's signed `state` cookie on this server-to-server response instead of the - // user's browser, so the OAuth callback would fail with `state_mismatch`. The - // authorize endpoint runs the link inside the user's browser, planting the - // cookie correctly while keeping the callback's state check enabled. - // - // The pending credential draft is created by that authorize endpoint at click - // time (not here), so the draft's TTL starts when the user actually initiates - // the connect and reliably outlives the OAuth round-trip. - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } - - return { url: authorizeUrl.toString(), providerId, serviceName } -} diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts new file mode 100644 index 00000000000..2ffb63ae2f2 --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -0,0 +1,23 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +export const credentialValidationParseOptions = { + validationErrorResponse: (error: Parameters[0]) => + validationErrorResponse(error, getValidationErrorMessage(error)), +} as const + +export const internalCredentialErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (!(error instanceof CredentialProviderOperationError)) return null + return internalErrorResponse(error.providerUnavailable ? 502 : 400, { + error: error.message, + code: error.providerErrorCode, + }) + } +) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts new file mode 100644 index 00000000000..6b81be90d82 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -0,0 +1,12 @@ +import type { WorkspaceDelegationPolicy } from '@/lib/core/application' + +export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' + +export const credentialDelegationPolicy = { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts new file mode 100644 index 00000000000..dbfe9f99157 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineCredentialOperation } from '@/lib/credentials/application/operations' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + getActor: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) + +const memberOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_member', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' +) +const adminOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' +) +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credential, +} + +function createUseCase(operation: typeof memberOperation | typeof adminOperation) { + return defineAuthorizedCredentialUseCase({ + operation, + resolveContext: async () => ({ ...context }), + execute: mocks.execute, + }) +} + +describe('defineAuthorizedCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.execute.mockResolvedValue({ ok: true }) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + }) + + it('allows an active credential member for member-level reads', async () => { + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledOnce() + }) + + it('requires credential admin independently of workspace read access', async () => { + await expect( + createUseCase(adminOperation).execute({ principal, input: undefined }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before resolving credential membership', async () => { + await createUseCase(memberOperation).execute({ principal, input: undefined }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts index 2fe52fb957e..635fc9eb621 100644 --- a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -6,16 +6,28 @@ import { type WorkspaceAuthorizationContext, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialActorContext } from '@/lib/credentials/access' import { getCredentialActorContext } from '@/lib/credentials/access' -import type { CredentialAdminOperation } from '@/lib/credentials/application/operations' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import type { CredentialOperation } from '@/lib/credentials/application/operations' import type { CredentialRow } from '@/lib/credentials/queries' export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { credential: CredentialRow + credentialAccess?: CredentialActorContext +} + +export function requireCredentialAccess( + context: CredentialAuthorizationContext +): CredentialActorContext { + if (!context.credentialAccess) { + throw new Error('Credential use case executed without resource authorization') + } + return context.credentialAccess } type AuthorizedCredentialUseCaseDefinition< - O extends CredentialAdminOperation, + O extends CredentialOperation, I, C extends CredentialAuthorizationContext, R, @@ -25,14 +37,14 @@ type AuthorizedCredentialUseCaseDefinition< > export function defineAuthorizedCredentialUseCase< - const O extends CredentialAdminOperation, + const O extends CredentialOperation, I, C extends CredentialAuthorizationContext, R, >(definition: AuthorizedCredentialUseCaseDefinition) { return defineAuthorizedWorkspaceUseCase({ ...definition, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, async authorizeResource({ principal, context }) { const actor = await getCredentialActorContext( context.credential.id, @@ -45,7 +57,13 @@ export function defineAuthorizedCredentialUseCase< ) { throw new OrchestrationError('not_found', 'Credential not found') } + context.credentialAccess = actor switch (definition.operation.minimumCredentialRole) { + case 'member': + if (!actor.member && !actor.isAdmin) { + throw new OrchestrationError('forbidden', 'Credential access required') + } + return case 'admin': if (!actor.isAdmin) { throw new ForbiddenOperationError( diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts new file mode 100644 index 00000000000..729bcbddf57 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -0,0 +1,73 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialUserOperation } from '@/lib/credentials/application/operations' + +export interface CredentialUserAuditEntry { + workspaceId: string | null + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +interface CredentialUserUseCaseDefinition { + operation: O + execute(args: { + principal: SessionPrincipal + input: I + request?: OrchestrationRequestContext + }): Promise + projectAudit?(args: { + principal: SessionPrincipal + input: I + result: R + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise +} + +/** Defines a current-user credential operation that cannot borrow workspace identity. */ +export function defineAuthorizedCredentialUserUseCase< + const O extends CredentialUserOperation, + I, + R, +>(definition: CredentialUserUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } + const result = await definition.execute({ principal, input, request }) + const projected = definition.projectAudit?.({ principal, input, result }) + if (projected) { + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: definition.operation.id, + actor: attribution.actor, + }, + request, + }) + } + } + await definition.afterSuccess?.({ principal, input, result }) + return result + }, + } +} diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts index 8783d526463..a7cb618ceab 100644 --- a/apps/sim/lib/credentials/application/connection-target.ts +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -18,13 +18,21 @@ export interface ResolvedCredentialConnectionTarget { displayName?: string } +export class CredentialConnectionProviderMismatchError extends OrchestrationError { + constructor() { + super('validation', 'Credential provider does not match the requested OAuth provider') + this.name = 'CredentialConnectionProviderMismatchError' + } +} + export async function resolveCredentialConnectionTarget(params: { principal: Principal context: ActiveWorkspaceApplicationContext providerId?: string credentialId?: string + assertedProviderId?: string }): Promise { - const { principal, context, providerId, credentialId } = params + const { principal, context, providerId, credentialId, assertedProviderId } = params if (Boolean(providerId) === Boolean(credentialId)) { throw new Error('Credential connection requires exactly one target identifier') } @@ -49,6 +57,9 @@ export async function resolveCredentialConnectionTarget(params: { throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') } const credentialProviderId = credential.providerId + if (assertedProviderId && assertedProviderId !== credentialProviderId) { + throw new CredentialConnectionProviderMismatchError() + } const actor = await getCredentialActorContext(targetCredentialId, userId) if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index 544edd2130b..9c3eabbbed8 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -81,7 +81,7 @@ describe('createCredentialConnection', () => { expect(mocks.loadWorkspace).not.toHaveBeenCalled() }) - it('creates a user-bound draft and returns only its browser entrypoint', async () => { + it('creates a user-bound draft and returns its canonical connection context', async () => { const result = await createCredentialConnection.execute({ principal: personalPrincipal, input: { @@ -101,7 +101,10 @@ describe('createCredentialConnection', () => { }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + draftId: 'draft-1', expiresAt: new Date('2026-08-12T20:15:00.000Z'), + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 4aaae28a61c..7a0d8491168 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -1,6 +1,8 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' import { credentialOperations } from '@/lib/credentials/application/operations' import { createConnectDraft } from '@/lib/credentials/connect-draft' @@ -9,13 +11,22 @@ import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/applicat export type CreateCredentialConnectionInput = { workspaceId: string } & ( - | { providerId: string; displayName: string; credentialId?: never } - | { credentialId: string; providerId?: never; displayName?: never } + | { providerId: string; displayName?: string; credentialId?: never } + | { + credentialId: string + assertedProviderId?: string + providerId?: never + displayName?: never + } ) export interface CreateCredentialConnectionResult { authorizationUrl: string + draftId: string expiresAt: Date + providerId: string + workspaceId: string + credentialId?: string } export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ @@ -25,30 +36,34 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context }, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, execute: async ({ principal, input, context }): Promise => { const target = await resolveCredentialConnectionTarget({ principal, context, providerId: input.providerId, credentialId: input.credentialId, + assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, }) const displayName = input.providerId ? input.displayName : target.displayName - if (!displayName) throw new Error('Resolved credential connection target has no display name') const draft = await createConnectDraft({ - userId: principal.userId, + userId: requirePrincipalSubjectUserId(principal), workspaceId: context.workspaceId, providerId: target.providerId, credentialId: target.credentialId, displayName, - displayNameDefinesIntent: input.providerId !== undefined, + displayNameDefinesIntent: input.providerId !== undefined && displayName !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) return { authorizationUrl: authorizationUrl.toString(), + draftId: draft.id, expiresAt: draft.expiresAt, + providerId: target.providerId, + workspaceId: context.workspaceId, + ...(target.credentialId ? { credentialId: target.credentialId } : {}), } }, }) diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts new file mode 100644 index 00000000000..d5fbf242545 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -0,0 +1,32 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' +import { getCredentialById, getWorkspaceCredential } from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolveCredentialApplicationContextInput { + credentialId: string + assertedWorkspaceId?: string +} + +/** Loads a credential canonically and verifies any asserted workspace scope. */ +export async function resolveCredentialApplicationContext( + input: ResolveCredentialApplicationContextInput +): Promise { + const assertedWorkspace = input.assertedWorkspaceId + ? await loadActiveWorkspaceApplicationContext(input.assertedWorkspaceId) + : null + if (input.assertedWorkspaceId && !assertedWorkspace) { + throw new OrchestrationError('not_found', 'Credential not found') + } + const credential = assertedWorkspace + ? await getWorkspaceCredential({ + workspaceId: assertedWorkspace.workspaceId, + credentialId: input.credentialId, + }) + : await getCredentialById(input.credentialId) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + const workspace = + assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + return { ...workspace, credential } +} diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts new file mode 100644 index 00000000000..a78460c291a --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -0,0 +1,239 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + defineAuthorizedCredentialUseCase, + requireCredentialAccess, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + createCredentialRecord, + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCredentialResult, + type PerformUpdateCredentialParams, + updateCredentialRecord, +} from '@/lib/credentials/orchestration' +import { + type CredentialRow, + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, +} from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export class CredentialProviderOperationError extends OrchestrationError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } +} + +function throwCredentialMutationFailure(result: { + success: boolean + error?: string + errorCode?: PerformCredentialResult['errorCode'] + providerErrorCode?: string + providerUnavailable?: boolean +}): never { + if (result.providerErrorCode) { + throw new CredentialProviderOperationError( + result.error ?? result.providerErrorCode, + result.providerErrorCode, + result.providerUnavailable === true || isProviderOutageCode(result.providerErrorCode) + ) + } + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential mutation failed') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? 'Credential mutation forbidden') + default: + throw new Error(result.error ?? 'Credential mutation failed') + } +} + +export interface ListInternalCredentialsInput { + workspaceId: string + type?: CredentialRow['type'] + providerId?: string + credentialId?: string +} + +export interface ListInternalCredentialsResult { + credentials: VisibleWorkspaceCredential[] + credential: VisibleWorkspaceCredential | null | undefined +} + +export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listInternal, + resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + const userId = requirePrincipalSubjectUserId(principal) + if (!input.type || input.type === 'oauth') { + await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) + } + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + const page = await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId, + workspaceAccess, + types: input.type ? [input.type] : undefined, + providerId: input.providerId, + }) + const lookup = input.credentialId + ? (page.data.find( + (candidate) => + candidate.id === input.credentialId || candidate.accountId === input.credentialId + ) ?? null) + : undefined + return { credentials: input.credentialId ? [] : page.data, credential: lookup } + }, +}) + +export type CreateWorkspaceCredentialInput = Omit< + PerformCreateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'request' +> + +export interface CreateWorkspaceCredentialResult { + credential: CredentialRow + created: boolean + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + auditMetadata: Record +} + +export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.create, + resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input }): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) + if (!result.success) throwCredentialMutationFailure(result) + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + const access = await getCredentialActorContext(result.credential.id, userId) + if (!access.credential || !canUseCredential(access)) { + throw new Error('Created credential is not visible to its creator') + } + const role = access.isAdmin ? 'admin' : access.member?.role + const status = access.member?.status ?? (access.isAdmin ? 'active' : undefined) + if (!role || !status) throw new Error('Created credential has no active actor membership') + return { + credential: access.credential, + created: result.created === true, + role, + status, + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface GetWorkspaceCredentialInput { + credentialId: string +} + +export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ context }) { + return { credential: context.credential, access: requireCredentialAccess(context) } + }, +}) + +export type UpdateWorkspaceCredentialInput = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> + +export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { + throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') + } + const result = await updateCredentialRecord({ ...input, credential: context.credential }) + if (!result.success) throwCredentialMutationFailure(result) + const access = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!access.credential || !access.isAdmin) { + throw new Error('Updated credential is no longer visible to its administrator') + } + return { + credential: access.credential, + access, + previousDisplayName: context.credential.displayName, + updatedFields: result.updatedFields ?? [], + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + resourceName: context.credential.displayName, + description: `Updated ${context.credential.type} credential "${context.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: context.credential.type, + updatedFields: result.updatedFields, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts new file mode 100644 index 00000000000..57ecb2d8796 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -0,0 +1,148 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { + credentialOperations, + credentialUserOperations, +} from '@/lib/credentials/application/operations' +import { + leaveCredentialMembership, + listCredentialMembers, + listCredentialMembershipsForUser, + removeCredentialMember, + upsertCredentialMember, +} from '@/lib/credentials/members' +import { captureServerEvent } from '@/lib/posthog/server' + +interface CredentialMemberResourceInput { + credentialId: string +} + +function resolveSessionCredentialContext( + _principal: SessionPrincipal, + input: CredentialMemberResourceInput +) { + return resolveCredentialApplicationContext(input) +} + +export const listCredentialMembersUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.listMembers, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: CredentialMemberResourceInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ context }) { + return { members: await listCredentialMembers(context.credential) } + }, +}) + +export interface UpsertCredentialMemberInput extends CredentialMemberResourceInput { + userId: string + role: 'admin' | 'member' +} + +export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.upsertMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: UpsertCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ principal, input, context }) { + const result = await upsertCredentialMember({ + credential: context.credential, + actorUserId: requirePrincipalSubjectUserId(principal), + targetUserId: input.userId, + role: input.role, + }) + return { ...result, targetUserId: input.userId, role: input.role } + }, + projectAudit: ({ context, result }) => ({ + action: result.created + ? AuditAction.CREDENTIAL_MEMBER_ADDED + : AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: result.created + ? `Shared credential with member as "${result.role}"` + : `Changed credential member role to "${result.role}"`, + metadata: { + targetUserId: result.targetUserId, + ...(result.created + ? { role: result.role } + : { fromRole: result.previousRole, toRole: result.role }), + }, + }), + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { + credential_type: context.credential.type, + role: result.role, + workspace_id: context.workspaceId, + }) + }, +}) + +export interface RemoveCredentialMemberInput extends CredentialMemberResourceInput { + userId: string +} + +export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.removeMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: RemoveCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ input, context }) { + await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) + return { success: true as const, targetUserId: input.userId } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: 'Removed credential member', + metadata: { targetUserId: result.targetUserId }, + }), + afterSuccess: ({ principal, context }) => { + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { + credential_type: context.credential.type, + workspace_id: context.workspaceId, + }) + }, +}) + +export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listMemberships, + async execute({ principal }) { + return { memberships: await listCredentialMembershipsForUser(principal.userId) } + }, +}) + +export interface LeaveCredentialMembershipInput { + credentialId: string +} + +export const leaveCredentialMembershipUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.leaveMembership, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: LeaveCredentialMembershipInput + }) { + await leaveCredentialMembership({ userId: principal.userId, credentialId: input.credentialId }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.test.ts b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts new file mode 100644 index 00000000000..c3154f33db1 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} + +function oauthCredential(id: string, workspaceId = 'workspace-1') { + return { + id, + workspaceId, + type: 'oauth' as const, + displayName: `OAuth ${id}`, + description: null, + providerId: 'google-email', + accountId: `account-${id}`, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-14T12:00:00.000Z'), + updatedAt: new Date('2026-08-14T12:00:00.000Z'), + } +} + +describe('deleteManyCredentialsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.deleteCredential.mockResolvedValue(true) + }) + + it('deletes only OAuth credentials administered in the delegated workspace', async () => { + const allowed = oauthCredential('credential-1') + mocks.getActor + .mockResolvedValueOnce({ + credential: allowed, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + .mockResolvedValueOnce({ + credential: oauthCredential('credential-2', 'workspace-2'), + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }, + }) + + expect(result).toEqual({ + deleted: ['credential-1'], + failed: ['credential-2'], + deletedCredentials: [allowed], + }) + expect(mocks.deleteCredential).toHaveBeenCalledOnce() + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: allowed, + reason: 'copilot_delete', + }) + }) + + it('rejects duplicate IDs before loading any credential', async () => { + await expect( + deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-1'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.ts b/apps/sim/lib/credentials/application/delete-many-credentials.ts new file mode 100644 index 00000000000..993f1795ef2 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('DeleteManyCredentialsApplication') +const MAX_CREDENTIAL_DELETE_BATCH = 20 + +export interface DeleteManyCredentialsInput { + workspaceId: string + credentialIds: string[] +} + +export interface DeleteManyCredentialsResult { + deleted: string[] + failed: string[] + deletedCredentials: CredentialRow[] +} + +export const deleteManyCredentialsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.deleteMany, + resolveContext: async ({ input }: { input: DeleteManyCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async execute({ principal, input, context }): Promise { + if (input.credentialIds.length === 0) { + throw new OrchestrationError('validation', 'At least one credential ID is required') + } + if (input.credentialIds.length > MAX_CREDENTIAL_DELETE_BATCH) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_CREDENTIAL_DELETE_BATCH} credentials can be deleted at once` + ) + } + if (new Set(input.credentialIds).size !== input.credentialIds.length) { + throw new OrchestrationError('validation', 'Credential IDs must be unique') + } + + const userId = requirePrincipalSubjectUserId(principal) + const deleted: string[] = [] + const failed: string[] = [] + const deletedCredentials: CredentialRow[] = [] + + for (const credentialId of input.credentialIds) { + try { + const access = await getCredentialActorContext(credentialId, userId) + if ( + !access.credential || + access.credential.workspaceId !== context.workspaceId || + !access.hasWorkspaceAccess || + !access.isAdmin || + access.credential.type !== 'oauth' + ) { + failed.push(credentialId) + continue + } + const didDelete = await deleteCredentialRecord({ + credential: access.credential, + reason: 'copilot_delete', + }) + if (!didDelete) { + failed.push(credentialId) + continue + } + deleted.push(credentialId) + deletedCredentials.push(access.credential) + } catch (error) { + logger.error('Failed to delete credential in Copilot batch', { credentialId, error }) + failed.push(credentialId) + } + } + + return { deleted, failed, deletedCredentials } + }, + projectAudit: ({ result }) => + result.deletedCredentials.map((credential) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (copilot_delete)`, + metadata: { + reason: 'copilot_delete', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + afterSuccess: ({ principal, context, result }) => { + for (const credential of result.deletedCredentials) { + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts index c7d11d848d9..a6dbde7cc43 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.test.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -41,17 +41,15 @@ describe('listCredentialProviders', () => { mocks.listCatalog.mockResolvedValue([]) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('allows sessions to inspect deployment availability', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect( - listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) - ).rejects.toMatchObject({ code: 'forbidden' }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) }) it('allows workspace keys to inspect deployment availability', async () => { diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts index b24d45ea811..d61c7209340 100644 --- a/apps/sim/lib/credentials/application/list-credential-providers.ts +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -1,5 +1,6 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' import { type CredentialProviderCatalogEntry, @@ -22,7 +23,7 @@ export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ if (!context) throw new OrchestrationError('not_found', 'Workspace not found') return context }, - authorizationOptions: {}, + authorizationOptions: { delegation: credentialDelegationPolicy }, execute: async ({ principal, context }): Promise => ({ providers: await listCredentialProviderCatalog(principal, context), }), diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts index f0dc64e48e3..c22e024cd61 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -54,21 +54,21 @@ describe('listWorkspaceCredentials', () => { mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) - mocks.listVisible.mockResolvedValue([]) - mocks.listForWorkspacePrincipal.mockResolvedValue([]) + mocks.listVisible.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listForWorkspacePrincipal.mockResolvedValue({ data: [], nextCursorKeys: null }) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('preserves per-credential visibility for sessions', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ - code: 'forbidden', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listWorkspaceCredentials.execute({ principal, input }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth', 'service_account'] }) + ) }) it('lists shared connections for a workspace key without creator identity', async () => { diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts new file mode 100644 index 00000000000..3c649416f06 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { credentialUserOperations } from '@/lib/credentials/application/operations' +import { + disconnectOAuthAccounts, + listConnectedAccountsForUser, + listOAuthConnectionsForUser, +} from '@/lib/credentials/oauth-accounts' +import { captureServerEvent } from '@/lib/posthog/server' + +export const listOAuthConnectionsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listOAuthConnections, + async execute({ principal }) { + return { connections: await listOAuthConnectionsForUser(principal.userId) } + }, +}) + +export interface ListConnectedAccountsInput { + provider?: string +} + +export const listConnectedAccountsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listConnectedAccounts, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: ListConnectedAccountsInput + }) { + return { + accounts: await listConnectedAccountsForUser({ + userId: principal.userId, + provider: input.provider, + }), + } + }, +}) + +export interface DisconnectOAuthInput { + provider: string + providerId?: string + accountId?: string +} + +export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.disconnectOAuth, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: DisconnectOAuthInput + }) { + const result = await disconnectOAuthAccounts({ userId: principal.userId, ...input }) + return { ...result, ...input, success: true as const } + }, + projectAudit: ({ result }) => [ + ...result.credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + { + workspaceId: null, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: result.providerId ?? result.provider, + resourceName: result.provider, + description: `Disconnected OAuth provider: ${result.provider}`, + metadata: { provider: result.provider, providerId: result.providerId }, + }, + ], + afterSuccess: ({ principal, result }) => { + for (const credential of result.credentials) { + captureServerEvent( + principal.userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? result.providerId ?? result.provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts index 2fd4091fb7b..26c363580f9 100644 --- a/apps/sim/lib/credentials/application/operations.test.ts +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { defineWorkspaceOperation } from '@/lib/core/application' import { credentialOperations, - defineCredentialAdminOperation, + defineCredentialOperation, } from '@/lib/credentials/application/operations' describe('credential operations', () => { @@ -15,7 +15,8 @@ describe('credential operations', () => { minimumRole: 'read', minimumCredentialRole: 'admin', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], }) expect(Object.isFrozen(credentialOperations.delete)).toBe(true) }) @@ -28,8 +29,8 @@ describe('credential operations', () => { principalKinds: ['workspace_api_key'], }) - expect(() => defineCredentialAdminOperation(workspaceKeyOperation)).toThrow( - 'Credential admin operation credentials.test_admin requires a human principal' + expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( + 'Credential operation credentials.test_admin requires a user-bearing principal' ) }) }) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index ec5b7181635..fbd1f8a5631 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,51 +1,143 @@ +import type { ApplicationOperation } from '@/lib/core/application' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' -export type CredentialAdminOperation = O & { - readonly minimumCredentialRole: 'admin' +export type CredentialRole = 'member' | 'admin' + +export type CredentialOperation = O & { + readonly minimumCredentialRole: CredentialRole } -/** Adds credential-admin policy to a workspace-scoped operation. */ -export function defineCredentialAdminOperation( - operation: O -): CredentialAdminOperation { +/** Adds credential-resource policy to a workspace-scoped operation. */ +export function defineCredentialOperation< + const O extends WorkspaceOperation, + const R extends CredentialRole, +>( + operation: O, + minimumCredentialRole: R +): CredentialOperation & { + readonly minimumCredentialRole: R +} { if (operation.principalKinds.includes('workspace_api_key')) { - throw new Error(`Credential admin operation ${operation.id} requires a human principal`) + throw new Error(`Credential operation ${operation.id} requires a user-bearing principal`) } - return Object.freeze({ ...operation, minimumCredentialRole: 'admin' as const }) + return Object.freeze({ ...operation, minimumCredentialRole }) } +const HUMAN_AND_COPILOT_PRINCIPALS = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const + export const credentialOperations = { + listInternal: defineWorkspaceOperation({ + id: 'credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), listProviders: defineWorkspaceOperation({ id: 'credentials.providers.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], }), createConnection: defineWorkspaceOperation({ id: 'credentials.connections.create', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key'], + }), + prepareConnection: defineWorkspaceOperation({ + id: 'credentials.connections.prepare', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], }), createServiceAccount: defineWorkspaceOperation({ id: 'credentials.service_accounts.create', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], + principalKinds: ['session', 'personal_api_key'], + }), + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + create: defineWorkspaceOperation({ + id: 'credentials.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), - delete: defineCredentialAdminOperation( + update: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + delete: defineCredentialOperation( defineWorkspaceOperation({ id: 'credentials.delete', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: ['personal_api_key'], - }) + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + deleteMany: defineWorkspaceOperation({ + id: 'credentials.delete_many', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + saveDraft: defineWorkspaceOperation({ + id: 'credentials.drafts.save', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listMembers: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + upsertMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.upsert', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + removeMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' ), launchConnection: defineWorkspaceOperation({ id: 'credentials.connections.launch', @@ -54,3 +146,23 @@ export const credentialOperations = { principalKinds: ['session'], }), } as const + +export interface CredentialUserOperation + extends ApplicationOperation { + readonly principalKinds: readonly ['session'] +} + +function defineCredentialUserOperation( + id: Id +): CredentialUserOperation { + if (!id.trim()) throw new Error('Credential user operation ID must not be empty') + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + +export const credentialUserOperations = { + listMemberships: defineCredentialUserOperation('credentials.memberships.list'), + leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), + disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), +} as const diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts new file mode 100644 index 00000000000..4d2e8f8ed27 --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} +const gmailProvider = { + type: 'oauth' as const, + serviceId: 'gmail', + name: 'Gmail', + description: 'Gmail OAuth', + providerFamily: 'google', + available: true, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'google-email', label: 'Gmail' }], +} + +describe('prepareCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.listCatalog.mockResolvedValue([gmailProvider]) + }) + + it('resolves a provider inside delegated workspace policy', async () => { + const result = await prepareCredentialConnection.execute({ + principal, + input: { workspaceId: 'workspace-1', providerName: 'gmail' }, + }) + + expect(result).toEqual({ providerId: 'google-email', serviceName: 'Gmail' }) + }) + + it('uses the credential target as the reconnect authority', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'google-email', + credentialId: 'credential-1', + }) + + const result = await prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ + providerId: 'google-email', + serviceName: 'Gmail', + credentialId: 'credential-1', + }) + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: workspace, + credentialId: 'credential-1', + }) + }) + + it('rejects a reconnect whose requested provider does not match the credential', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'slack', + credentialId: 'credential-1', + }) + + await expect( + prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts new file mode 100644 index 00000000000..cedd08e89fd --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts @@ -0,0 +1,109 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface PrepareCredentialConnectionInput { + workspaceId: string + providerName: string + credentialId?: string +} + +export interface PrepareCredentialConnectionResult { + providerId: string + serviceName: string + credentialId?: string +} + +function resolveRequestedProvider( + providers: readonly OAuthCredentialProviderCatalogEntry[], + providerName: string +): OAuthCredentialProviderCatalogEntry { + const requested = providerName.toLowerCase().trim() + if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required') + + const provider = + providers.find((entry) => + entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested) + ) ?? + providers.find( + (entry) => + entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested + ) ?? + providers.find( + (entry) => + entry.name.toLowerCase().includes(requested) || + requested.includes(entry.name.toLowerCase()) || + entry.authorizationOptions.some( + (option) => + option.providerId.toLowerCase().includes(requested) || + requested.includes(option.providerId.toLowerCase()) + ) + ) + + if (!provider) + throw new OrchestrationError('validation', `OAuth provider not found: ${providerName}`) + if (!provider.available) { + throw new OrchestrationError('conflict', `${provider.name} is not available in this workspace`) + } + return provider +} + +export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.prepareConnection, + resolveContext: async ({ input }: { input: PrepareCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth' + ) + const requestedProvider = resolveRequestedProvider(providers, input.providerName) + const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId + if (!requestedProviderId) { + throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`) + } + + if (!input.credentialId) { + return { + providerId: requestedProviderId, + serviceName: requestedProvider.name, + } + } + + const target = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: input.credentialId, + }) + if ( + !credentialProviderMatchesService(target.providerId, { + providerId: requestedProviderId, + additionalProviderIds: requestedProvider.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) { + throw new OrchestrationError( + 'validation', + `Credential belongs to provider ${target.providerId}, not ${requestedProviderId}` + ) + } + + return { + providerId: target.providerId, + serviceName: requestedProvider.name, + credentialId: target.credentialId, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts index efbf73c0d07..3ff11684906 100644 --- a/apps/sim/lib/credentials/application/presentation.ts +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -1,4 +1,6 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import type { CredentialActorContext } from '@/lib/credentials/access' import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' type PublicCredentialSource = @@ -24,3 +26,30 @@ export function toV2Credential(row: PublicCredentialSource): V2Credential { updatedAt: row.updatedAt.toISOString(), } } + +/** Serializes credential metadata for the internal workspace surface. */ +export function toWorkspaceCredential( + row: CredentialRow | VisibleWorkspaceCredential, + access?: CredentialActorContext +): WorkspaceCredential { + const role = access?.isAdmin + ? 'admin' + : (access?.member?.role ?? ('role' in row ? row.role : undefined)) + const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + return { + id: row.id, + workspaceId: row.workspaceId, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + envOwnerUserId: row.envOwnerUserId, + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + } +} diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts new file mode 100644 index 00000000000..abdd2da0d16 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + createDraft: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} + +describe('saveCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.createDraft.mockResolvedValue({ id: 'draft-1' }) + }) + + it('authorizes workspace access before resolving reconnect credential access', async () => { + await saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + replaceExistingIntent: true, + }) + ) + }) + + it('rejects a reconnect outside the asserted workspace', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, workspaceId: 'workspace-2' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts new file mode 100644 index 00000000000..6fc002c83c7 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -0,0 +1,67 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface SaveCredentialDraftInput { + workspaceId: string + providerId: string + displayName: string + description?: string + credentialId?: string +} + +interface SaveCredentialDraftContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credentialAccess?: CredentialActorContext +} + +export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.saveDraft, + async resolveContext({ + input, + }: { + input: SaveCredentialDraftInput + }): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace + }, + authorizationOptions: {}, + async authorizeResource({ principal, input, context }) { + if (!input.credentialId) return + context.credentialAccess = await getCredentialActorContext( + input.credentialId, + requirePrincipalSubjectUserId(principal) + ) + if ( + !context.credentialAccess?.credential || + context.credentialAccess.credential.workspaceId !== context.workspaceId || + !context.credentialAccess.isAdmin + ) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access required on the target credential' + ) + } + }, + async execute({ principal, input }) { + await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: input.workspaceId, + providerId: input.providerId, + displayName: input.displayName, + description: input.description, + credentialId: input.credentialId, + displayNameDefinesIntent: true, + replaceExistingIntent: true, + }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts index d72c1911864..c99ac568058 100644 --- a/apps/sim/lib/credentials/application/service-account.ts +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -1,14 +1,12 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import type { Principal } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { OrchestrationError } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' import { getCredentialActorContext } from '@/lib/credentials/access' -import { - type CredentialAuthorizationContext, - defineAuthorizedCredentialUseCase, -} from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' import { credentialOperations } from '@/lib/credentials/application/operations' import { listCredentialProviderCatalog, @@ -18,8 +16,9 @@ import { type CreateServiceAccountCredentialParams, createServiceAccountCredential, deleteConnectionCredential, + deleteCredentialRecord, } from '@/lib/credentials/orchestration' -import { type CredentialRow, getWorkspaceCredential } from '@/lib/credentials/queries' +import type { CredentialRow } from '@/lib/credentials/queries' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' @@ -45,10 +44,6 @@ class CredentialProviderUnavailableError extends HttpError { } } -function principalUserId(principal: Extract): string { - return principal.userId -} - export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.createServiceAccount, resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { @@ -63,7 +58,7 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs const result = await createServiceAccountCredential({ ...input, workspaceId: context.workspaceId, - userId: principalUserId(principal), + userId: requirePrincipalSubjectUserId(principal), request, }) if (!result.success) { @@ -85,7 +80,10 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs if (!result.credential) { throw new Error('Credential creation succeeded without a credential') } - const actor = await getCredentialActorContext(result.credential.id, principalUserId(principal)) + const actor = await getCredentialActorContext( + result.credential.id, + requirePrincipalSubjectUserId(principal) + ) if (!actor.credential || (!actor.member && !actor.isAdmin)) { throw new Error('Created credential is not visible to its creator') } @@ -115,7 +113,7 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs afterSuccess: ({ principal, context, result }) => { if (!result.created) return captureServerEvent( - principalUserId(principal), + requirePrincipalSubjectUserId(principal), 'credential_connected', { credential_type: 'service_account', @@ -130,12 +128,8 @@ export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUs }, }) -interface CredentialApplicationContext extends CredentialAuthorizationContext { - billedAccountUserId: string -} - export interface DeleteCredentialInput { - workspaceId: string + workspaceId?: string credentialId: string } @@ -144,43 +138,47 @@ export interface DeleteCredentialResult { deleted: boolean } -async function resolveCredentialContext( - input: DeleteCredentialInput -): Promise { - const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) - if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') - const credential = await getWorkspaceCredential({ - workspaceId: workspace.workspaceId, - credentialId: input.credentialId, - }) - if (!credential || !['oauth', 'service_account'].includes(credential.type)) { - throw new OrchestrationError('not_found', 'Credential not found') - } - return { ...workspace, credential } -} - export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ operation: credentialOperations.delete, - resolveContext: async ({ input }: { input: DeleteCredentialInput }) => - resolveCredentialContext(input), - async execute({ input, context }): Promise { - const deleted = await deleteConnectionCredential({ + resolveContext: ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialApplicationContext({ credentialId: input.credentialId, - workspaceId: context.workspaceId, - reason: 'user_delete', - }) + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }): Promise { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(context.credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const deleted = + context.credential.type === 'oauth' || context.credential.type === 'service_account' + ? await deleteConnectionCredential({ + credentialId: context.credential.id, + workspaceId: context.workspaceId, + reason, + }) + : await deleteCredentialRecord({ credential: context.credential, reason }) return { credential: context.credential, deleted } }, - projectAudit: ({ result }) => + projectAudit: ({ principal, result }) => result.deleted ? { action: AuditAction.CREDENTIAL_DELETED, resourceType: AuditResourceType.CREDENTIAL, resourceId: result.credential.id, resourceName: result.credential.displayName, - description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (user_delete)`, + description: `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete'})`, metadata: { - reason: 'user_delete', + reason: principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete', credentialType: result.credential.type, providerId: result.credential.providerId, accountId: result.credential.accountId, @@ -190,10 +188,10 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ afterSuccess: ({ principal, context, result }) => { if (!result.deleted) return captureServerEvent( - principalUserId(principal), + requirePrincipalSubjectUserId(principal), 'credential_deleted', { - credential_type: result.credential.type as 'oauth' | 'service_account', + credential_type: result.credential.type, provider_id: result.credential.providerId ?? result.credential.id, workspace_id: context.workspaceId, }, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 6c2565e3d55..b41e2693412 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -29,8 +29,11 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string + description?: string /** Whether an explicitly requested name distinguishes this new-connection intent. */ displayNameDefinesIntent?: boolean + /** Replaces a pending intent for the same user, provider, and workspace. */ + replaceExistingIntent?: boolean }): Promise { const { userId, workspaceId, providerId, credentialId } = params @@ -83,6 +86,7 @@ export async function createConnectDraft(params: { workspaceId, providerId, displayName, + description: params.description?.trim() || null, credentialId: credentialId ?? null, expiresAt, createdAt: now, @@ -93,8 +97,16 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: { expiresAt, createdAt: now }, - setWhere: sameIntent, + set: params.replaceExistingIntent + ? { + displayName, + description: params.description?.trim() || null, + credentialId: credentialId ?? null, + expiresAt, + createdAt: now, + } + : { expiresAt, createdAt: now }, + ...(params.replaceExistingIntent ? {} : { setWhere: sameIntent }), }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts new file mode 100644 index 00000000000..3e1ed1985a6 --- /dev/null +++ b/apps/sim/lib/credentials/members.ts @@ -0,0 +1,265 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSharedCredentialType } from '@/lib/credentials/access' +import type { CredentialRow } from '@/lib/credentials/queries' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' + +export interface CredentialMemberView { + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: Date | null + userName: string | null + userEmail: string | null + roleSource: 'explicit' | 'workspace-admin' +} + +export async function listCredentialMembers( + credential: CredentialRow +): Promise { + const explicitMembers = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credential.id)) + + const byUser = new Map( + explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + ) + + if (isSharedCredentialType(credential.type)) { + const workspaceMembers = await getUsersWithPermissions(credential.workspaceId) + for (const workspaceMember of workspaceMembers) { + if (workspaceMember.permissionType !== 'admin') continue + const existing = byUser.get(workspaceMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(workspaceMember.userId, { + id: `workspace-admin-${workspaceMember.userId}`, + userId: workspaceMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: workspaceMember.name, + userEmail: workspaceMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + return Array.from(byUser.values()) +} + +export interface UpsertCredentialMemberParams { + credential: CredentialRow + actorUserId: string + targetUserId: string + role: 'admin' | 'member' +} + +export interface UpsertCredentialMemberResult { + created: boolean + previousRole?: 'admin' | 'member' +} + +export async function upsertCredentialMember( + params: UpsertCredentialMemberParams +): Promise { + if (!isSharedCredentialType(params.credential.type)) { + throw new OrchestrationError('validation', 'Personal secrets cannot be shared') + } + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === null) { + throw new OrchestrationError( + 'validation', + 'Target user must belong to the credential workspace' + ) + } + if (targetWorkspacePermission === 'admin' && params.role !== 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be demoted' + ) + } + + const [existing] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId) + ) + ) + .limit(1) + const now = new Date() + if (existing) { + const previousRole = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (!current) throw new Error('Credential membership disappeared during update') + await tx + .update(credentialMember) + .set({ role: params.role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return current.role + }) + return { created: false, previousRole } + } + + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: params.credential.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, + }) + return { created: true } +} + +export async function removeCredentialMember(params: { + credential: CredentialRow + targetUserId: string +}): Promise { + const [target] = await db + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + + if (isSharedCredentialType(params.credential.type)) { + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be removed' + ) + } + } + + const revoked = await db.transaction(async (tx) => { + if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + return true + }) + if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') +} + +export async function listCredentialMembershipsForUser(userId: string) { + return db + .select({ + membershipId: credentialMember.id, + credentialId: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + providerId: credential.providerId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + }) + .from(credentialMember) + .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) + .where(eq(credentialMember.userId, userId)) +} + +export async function leaveCredentialMembership(params: { + userId: string + credentialId: string +}): Promise { + const [membership] = await db + .select() + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.userId, params.userId) + ) + ) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Membership not found') + if (membership.status !== 'active') return + + const revoked = await db.transaction(async (tx) => { + if (membership.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, membership.id)) + return true + }) + if (!revoked) { + throw new OrchestrationError('validation', 'Cannot leave credential as the last active admin') + } +} diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts new file mode 100644 index 00000000000..8322b1f551d --- /dev/null +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -0,0 +1,135 @@ +import { db } from '@sim/db' +import { account, credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, inArray, like, or } from 'drizzle-orm' +import { decodeJwt } from 'jose' +import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { OAuthProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' +import { providerIdsForService } from '@/lib/oauth/utils' + +const logger = createLogger('CredentialOAuthAccounts') + +interface GoogleIdToken { + email?: string + name?: string +} + +export async function listOAuthConnectionsForUser(userId: string): Promise { + const [accounts, userRecord] = await Promise.all([ + db.select().from(account).where(eq(account.userId, userId)), + db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1), + ]) + const userEmail = userRecord[0]?.email ?? null + const connections: OAuthConnection[] = [] + + for (const accountRow of accounts) { + const { baseProvider, featureType } = parseProvider(accountRow.providerId as OAuthProvider) + if (!baseProvider) continue + const scopes = accountRow.scope?.split(/\s+/).filter(Boolean) ?? [] + let displayName = '' + if (accountRow.idToken) { + try { + const decoded = decodeJwt(accountRow.idToken) + displayName = decoded.email || decoded.name || '' + } catch (error) { + logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + } + } + if (!displayName && baseProvider === 'github') { + displayName = `${accountRow.accountId} (GitHub)` + } + displayName ||= userEmail || `${accountRow.accountId} (${baseProvider})` + + const existing = connections.find((connection) => connection.provider === accountRow.providerId) + if (existing) { + existing.accounts.push({ id: accountRow.id, name: displayName }) + existing.scopes = Array.from(new Set([...existing.scopes, ...scopes])) + if (accountRow.updatedAt.getTime() > new Date(existing.lastConnected).getTime()) { + existing.lastConnected = accountRow.updatedAt.toISOString() + } + continue + } + connections.push({ + provider: accountRow.providerId, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: accountRow.updatedAt.toISOString(), + accounts: [{ id: accountRow.id, name: displayName }], + }) + } + + return connections +} + +export async function listConnectedAccountsForUser(params: { userId: string; provider?: string }) { + const whereConditions = [eq(account.userId, params.userId)] + if (params.provider) whereConditions.push(eq(account.providerId, params.provider)) + const rows = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + credentialDisplayName: credential.displayName, + }) + .from(account) + .leftJoin(credential, eq(credential.accountId, account.id)) + .where(and(...whereConditions)) + .orderBy(desc(account.updatedAt)) + + const seen = new Map() + for (const row of rows) { + if (!seen.has(row.id)) seen.set(row.id, row) + } + return Array.from(seen.values()).map((row) => ({ + id: row.id, + accountId: row.accountId, + providerId: row.providerId, + displayName: row.credentialDisplayName || row.accountId || row.providerId, + })) +} + +export interface DisconnectOAuthAccountsParams { + userId: string + provider: string + providerId?: string + accountId?: string +} + +export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { + const accountFilter = params.accountId + ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) + : params.providerId + ? and(eq(account.userId, params.userId), eq(account.providerId, params.providerId)) + : and( + eq(account.userId, params.userId), + or( + inArray(account.providerId, providerIdsForService(params.provider)), + like(account.providerId, `${params.provider}-%`) + ) + ) + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccountIds = targetAccounts.map((row) => row.id) + if (targetAccountIds.length === 0) return { credentials: [] } + + const credentialRows = await db + .select() + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + const deletedCredentials: typeof credentialRows = [] + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db.delete(account).where(inArray(account.id, targetAccountIds)) + return { credentials: deletedCredentials } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index ecda5d7d249..8a54565ed33 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -193,7 +193,7 @@ function failure( return { success: false, error, errorCode, ...extra } } -async function createCredentialRecord( +export async function createCredentialRecord( params: PerformCreateCredentialParams, options: { authorizeWorkspace: boolean } ): Promise { diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 31afb8701d0..a403463467d 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -12,7 +12,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -31,10 +31,12 @@ import { import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +type CredentialRow = typeof credential.$inferSelect export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { type CreateServiceAccountCredentialParams, + createCredentialRecord, createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, @@ -162,38 +164,26 @@ export interface PerformCredentialResult { workspaceId?: string updatedFields?: string[] previousDisplayName?: string + auditMetadata?: Record } -export async function performUpdateCredential( - params: PerformUpdateCredentialParams +export type UpdateCredentialRecordParams = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> & { credential: CredentialRow } + +/** Updates one already-authorized credential without surface authorization or audit. */ +export async function updateCredentialRecord( + params: UpdateCredentialRecordParams ): Promise { try { - const access = await getCredentialActorContext(params.credentialId, params.userId) - if (!access.credential) { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return { - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - } - } - if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { - return { - success: false, - error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, - errorCode: 'validation', - } - } - const updates: Record = {} if (params.description !== undefined) { updates.description = params.description ?? null } if ( params.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') + (params.credential.type === 'oauth' || params.credential.type === 'service_account') ) { updates.displayName = params.displayName } @@ -218,8 +208,8 @@ export async function performUpdateCredential( params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - if (hasRotationSecret && access.credential.type === 'service_account') { - const providerId = access.credential.providerId ?? '' + if (hasRotationSecret && params.credential.type === 'service_account') { + const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual @@ -249,7 +239,7 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity - ? await readStoredSecretBlob(access.credential.id) + ? await readStoredSecretBlob(params.credential.id) : null try { @@ -280,7 +270,7 @@ export async function performUpdateCredential( const previousIdentity = deriveStoredDisplayName(storedBlob) if ( previousIdentity !== undefined && - previousIdentity === access.credential.displayName && + previousIdentity === params.credential.displayName && secret.displayName && secret.displayName !== previousIdentity ) { @@ -314,7 +304,7 @@ export async function performUpdateCredential( } if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if (params.credential.type === 'oauth' || params.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } } return { @@ -343,31 +333,12 @@ export async function performUpdateCredential( } const updatedFields = auditUpdatedFields(updates) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - // Provider metadata first: the orchestration's own keys stay authoritative - // and can never be shadowed by a builder's audit payload. - metadata: { - ...rotatedAuditMetadata, - credentialType: access.credential.type, - updatedFields, - }, - request: params.request, - }) - return { success: true, - workspaceId: access.credential.workspaceId, + workspaceId: params.credential.workspaceId, updatedFields, - previousDisplayName: access.credential.displayName, + previousDisplayName: params.credential.displayName, + auditMetadata: rotatedAuditMetadata, } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { @@ -382,6 +353,137 @@ export async function performUpdateCredential( } } +/** Preserves the legacy callers while application adapters migrate to the manager above. */ +export async function performUpdateCredential( + params: PerformUpdateCredentialParams +): Promise { + const access = await getCredentialActorContext(params.credentialId, params.userId) + if (!access.credential) { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return { + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + } + } + if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { + return { + success: false, + error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, + errorCode: 'validation', + } + } + + const result = await updateCredentialRecord({ ...params, credential: access.credential }) + if (!result.success) return result + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: access.credential.type, + updatedFields: result.updatedFields, + }, + request: params.request, + }) + + return result +} + +export interface DeleteCredentialRecordParams { + credential: CredentialRow + reason: CredentialDeleteReason +} + +/** Deletes one already-authorized credential and its backing secret source. */ +export async function deleteCredentialRecord( + params: DeleteCredentialRecordParams +): Promise { + const { credential: credentialRow } = params + + if (credentialRow.type === 'env_personal') { + if (!credentialRow.envKey || !credentialRow.envOwnerUserId) { + throw new Error('Personal environment credential is missing its source identity') + } + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, credentialRow.envOwnerUserId)) + .limit(1) + const current = { ...((personalRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(environment) + .values({ + id: credentialRow.envOwnerUserId, + userId: credentialRow.envOwnerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + await syncPersonalEnvCredentialsForUser({ + userId: credentialRow.envOwnerUserId, + envKeys: Object.keys(current), + }) + return true + } + + if (credentialRow.type === 'env_workspace') { + if (!credentialRow.envKey) { + throw new Error('Workspace environment credential is missing its source identity') + } + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId)) + .limit(1) + const current = { ...((workspaceRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id ?? generateId(), + workspaceId: credentialRow.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt ?? new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + await deleteWorkspaceEnvCredentials({ + workspaceId: credentialRow.workspaceId, + removedKeys: [credentialRow.envKey], + }) + return true + } + + return deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) +} + +/** Preserves the legacy callers while application adapters migrate to the manager above. */ export async function performDeleteCredential( params: CredentialActorParams ): Promise { @@ -405,149 +507,47 @@ export async function performDeleteCredential( } } - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' } - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(environment) - .values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - await deleteCredential({ - credentialId: params.credentialId, - actorId: params.userId, - actorName: params.actorName, - actorEmail: params.actorEmail, - reason: params.reason ?? 'user_delete', - request: params.request, - }) + const reason = params.reason ?? 'user_delete' + await deleteCredentialRecord({ credential: access.credential, reason }) captureServerEvent( params.userId, 'credential_deleted', { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? params.credentialId, + credential_type: access.credential.type, + provider_id: + access.credential.providerId ?? access.credential.envKey ?? params.credentialId, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) + const envDescription = + access.credential.type === 'env_personal' + ? `Deleted personal env credential "${access.credential.envKey}"` + : access.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${access.credential.envKey}"` + : `Deleted ${access.credential.type} credential "${access.credential.displayName}" (${reason})` + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: envDescription, + metadata: { + reason, + credentialType: access.credential.type, + providerId: access.credential.providerId, + accountId: access.credential.accountId, + envKey: access.credential.envKey, + }, + request: params.request, + }) + return { success: true, workspaceId: access.credential.workspaceId } } catch (error) { logger.error('Failed to delete credential', { error }) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index bacb9d2cda7..abf1b3b6a0f 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -280,3 +280,9 @@ export async function getWorkspaceCredential(params: { .limit(1) return row ?? null } + +/** Canonical credential lookup used before its workspace scope is known. */ +export async function getCredentialById(credentialId: string): Promise { + const [row] = await db.select().from(credential).where(eq(credential.id, credentialId)).limit(1) + return row ?? null +} From 96ef2bd1d3561e2680662f9a23b89c0d463ae6d5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 14:39:24 -0700 Subject: [PATCH 12/18] fix(credentials): keep OAuth draft intent immutable --- apps/docs/openapi-v2-resources.json | 5 ++ .../auth/instagram/authorize/route.test.ts | 49 ++++++++++++------- .../app/api/auth/instagram/authorize/route.ts | 35 ++++++++----- .../app/api/auth/oauth2/authorize/route.ts | 2 +- apps/sim/lib/auth/auth.ts | 6 ++- .../application/save-credential-draft.test.ts | 2 +- .../application/save-credential-draft.ts | 1 - apps/sim/lib/credentials/connect-draft.ts | 14 +----- apps/sim/lib/credentials/draft-processor.ts | 2 + apps/sim/lib/credentials/oauth-draft-state.ts | 1 - 10 files changed, 71 insertions(+), 46 deletions(-) delete mode 100644 apps/sim/lib/credentials/oauth-draft-state.ts diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 84d41b1cf71..d36683d8fb0 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -1821,6 +1821,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace used to evaluate credential-provider availability and integration policy." } } @@ -1967,6 +1968,7 @@ "schema": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace expected to own the credential." } } @@ -4760,6 +4762,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that will own the credential." }, "type": { @@ -4940,6 +4943,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace that will own the credential." }, "providerId": { @@ -4964,6 +4968,7 @@ "workspaceId": { "type": "string", "minLength": 1, + "maxLength": 128, "description": "Workspace expected to own the credential." }, "credentialId": { diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts index 7c5eb98a75a..125a05686ed 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.test.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -5,8 +5,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - checkWorkspaceAccess: vi.fn(), - createConnectDraft: vi.fn(), + createCredentialConnection: vi.fn(), getSession: vi.fn(), requireConfiguredOAuthClient: vi.fn(), })) @@ -23,18 +22,15 @@ vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test', })) -vi.mock('@/lib/credentials/connect-draft', () => ({ - createConnectDraft: mocks.createConnectDraft, +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { execute: mocks.createCredentialConnection }, })) vi.mock('@/lib/oauth/utils', () => ({ getCanonicalScopesForProvider: () => ['instagram_business_basic'], })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mocks.checkWorkspaceAccess, -})) - +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/auth/instagram/authorize/route' describe('Instagram authorize route', () => { @@ -47,8 +43,7 @@ describe('Instagram authorize route', () => { mocks.requireConfiguredOAuthClient.mockReturnValue({ values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, }) - mocks.checkWorkspaceAccess.mockResolvedValue({ canWrite: true }) - mocks.createConnectDraft.mockResolvedValue({ id: 'draft-created' }) + mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' }) }) it('preserves an exact credential draft when workspaceId is also supplied', async () => { @@ -65,8 +60,7 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-exact' ) - expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() - expect(mocks.createConnectDraft).not.toHaveBeenCalled() + expect(mocks.createCredentialConnection).not.toHaveBeenCalled() }) it('creates a credential draft for a legacy workspace-only launch', async () => { @@ -83,11 +77,32 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-created' ) - expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(mocks.createConnectDraft).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - providerId: 'instagram', + expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', providerId: 'instagram' }, + request, + }) + }) + + it('returns a conflict when a different connection intent is already active', async () => { + mocks.createCredentialConnection.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: 'A different OAuth connection flow is already active for this provider', }) }) }) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 84b0154e706..78bc81deb48 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -5,12 +5,12 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InstagramAuthorize') @@ -28,6 +28,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { values: { INSTAGRAM_CLIENT_ID: clientId }, @@ -39,16 +41,27 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let credentialDraftId = draftId if (workspaceId && !draftId) { - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.canWrite) { - return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + try { + const connection = await createCredentialConnection.execute({ + principal: { kind: 'session', userId: session.user.id, sessionId }, + input: { workspaceId, providerId: 'instagram' }, + request, + }) + credentialDraftId = connection.draftId + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'conflict') { + logger.warn('Rejected conflicting Instagram OAuth connection intent', { + userId: session.user.id, + workspaceId, + }) + return NextResponse.json({ error: classified.message }, { status: 409 }) + } + if (classified?.code === 'forbidden' || classified?.code === 'not_found') { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + throw error } - const draft = await createConnectDraft({ - userId: session.user.id, - workspaceId, - providerId: 'instagram', - }) - credentialDraftId = draft.id } const baseUrl = getBaseUrl() diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 61ce6823600..7526b1b67e8 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -11,7 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor' const logger = createLogger('OAuth2Authorize') diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index dc4ba62ca70..b06bd531e0a 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -96,8 +96,10 @@ import { } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/oauth-draft-state' +import { + OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts index abdd2da0d16..c7e463e10c9 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.test.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -77,7 +77,7 @@ describe('saveCredentialDraft', () => { userId: 'user-1', workspaceId: 'workspace-1', credentialId: 'credential-1', - replaceExistingIntent: true, + displayNameDefinesIntent: true, }) ) }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index 6fc002c83c7..8b27db621eb 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -60,7 +60,6 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ description: input.description, credentialId: input.credentialId, displayNameDefinesIntent: true, - replaceExistingIntent: true, }) return { success: true as const } }, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index b41e2693412..9ecd5efe825 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -32,8 +32,6 @@ export async function createConnectDraft(params: { description?: string /** Whether an explicitly requested name distinguishes this new-connection intent. */ displayNameDefinesIntent?: boolean - /** Replaces a pending intent for the same user, provider, and workspace. */ - replaceExistingIntent?: boolean }): Promise { const { userId, workspaceId, providerId, credentialId } = params @@ -97,16 +95,8 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: params.replaceExistingIntent - ? { - displayName, - description: params.description?.trim() || null, - credentialId: credentialId ?? null, - expiresAt, - createdAt: now, - } - : { expiresAt, createdAt: now }, - ...(params.replaceExistingIntent ? {} : { setWhere: sameIntent }), + set: { expiresAt, createdAt: now }, + setWhere: sameIntent, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index fc3637845f0..32b46d686a2 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -9,6 +9,8 @@ import { const logger = createLogger('CredentialDraftProcessor') +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' + interface ProcessCredentialDraftParams { draftId?: string userId: string diff --git a/apps/sim/lib/credentials/oauth-draft-state.ts b/apps/sim/lib/credentials/oauth-draft-state.ts deleted file mode 100644 index 3dd068a1bed..00000000000 --- a/apps/sim/lib/credentials/oauth-draft-state.ts +++ /dev/null @@ -1 +0,0 @@ -export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' From 147b1613cd014ae8d7f30b0c447ea5a934710f78 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 14:52:23 -0700 Subject: [PATCH 13/18] fix(credentials): allow renamed reconnect targets --- .../lib/credentials/application/save-credential-draft.test.ts | 2 +- apps/sim/lib/credentials/application/save-credential-draft.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts index c7e463e10c9..a3c6c1f81c4 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.test.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -77,7 +77,7 @@ describe('saveCredentialDraft', () => { userId: 'user-1', workspaceId: 'workspace-1', credentialId: 'credential-1', - displayNameDefinesIntent: true, + displayNameDefinesIntent: false, }) ) }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index 8b27db621eb..a058f1190f4 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -59,7 +59,7 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ displayName: input.displayName, description: input.description, credentialId: input.credentialId, - displayNameDefinesIntent: true, + displayNameDefinesIntent: input.credentialId === undefined, }) return { success: true as const } }, From 7b7429977d10560657ff51fa1a25e28ec7532955 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 15:05:15 -0700 Subject: [PATCH 14/18] fix(credentials): close OAuth draft edge cases --- .../auth/instagram/authorize/route.test.ts | 1 + .../app/api/auth/instagram/authorize/route.ts | 8 ++-- .../app/api/auth/trello/authorize/route.ts | 8 ++-- apps/sim/lib/auth/auth.ts | 21 +++++---- apps/sim/lib/credentials/connect-draft.ts | 4 +- apps/sim/lib/credentials/draft-constants.ts | 2 + apps/sim/lib/credentials/draft-hooks.test.ts | 45 +++++++++++++++++++ apps/sim/lib/credentials/draft-hooks.ts | 15 ++++--- .../lib/credentials/draft-processor.test.ts | 22 ++++++++- apps/sim/lib/credentials/draft-processor.ts | 9 ++++ apps/sim/lib/oauth/shopify-state.test.ts | 8 +++- apps/sim/lib/oauth/shopify-state.ts | 4 +- 12 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 apps/sim/lib/credentials/draft-constants.ts create mode 100644 apps/sim/lib/credentials/draft-hooks.test.ts diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts index 125a05686ed..66cb5506c97 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.test.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -77,6 +77,7 @@ describe('Instagram authorize route', () => { expect(response.headers.get('set-cookie')).toContain( 'instagram_credential_draft_id=draft-created' ) + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: 'workspace-1', providerId: 'instagram' }, diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index 78bc81deb48..17f21e99e66 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -10,6 +10,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('InstagramAuthorize') @@ -20,7 +21,6 @@ const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' -const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -81,7 +81,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) if (credentialDraftId) { @@ -89,7 +89,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } else { @@ -104,7 +104,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index c24cd32d61d..f98aaf5aab8 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') @@ -18,7 +19,6 @@ const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' -const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -58,7 +58,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) if (draftId) { @@ -66,7 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { @@ -80,7 +80,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index b06bd531e0a..43fbdf2a980 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,7 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { - OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, + parseCredentialDraftIdFromCallbackUrl, processCredentialDraft, } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -529,14 +529,17 @@ export const auth = betterAuth({ let credentialDraftId: string | undefined try { const oauthState = await getOAuthState() - const rawCallbackUrl = oauthState?.callbackURL - if (rawCallbackUrl !== undefined && typeof rawCallbackUrl !== 'string') { - throw new Error('OAuth state callback URL must be a string') - } - credentialDraftId = rawCallbackUrl - ? (new URL(rawCallbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? - undefined) - : undefined + credentialDraftId = parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL) + } catch (error) { + logger.error('[account.create.after] Failed to read OAuth credential draft state', { + userId: account.userId, + providerId: account.providerId, + error, + }) + throw error + } + + try { await processCredentialDraft({ draftId: credentialDraftId, userId: account.userId, diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 9ecd5efe825..bda705b4744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -5,10 +5,10 @@ import { generateId } from '@sim/utils/id' import { and, eq, gt, isNull, lt } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') -const DRAFT_TTL_MS = 15 * 60 * 1000 export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect @@ -63,7 +63,7 @@ export async function createConnectDraft(params: { } const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + const expiresAt = new Date(now.getTime() + CREDENTIAL_DRAFT_TTL_MS) await db .delete(pendingCredentialDraft) .where( diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts new file mode 100644 index 00000000000..ff7525a35f9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -0,0 +1,2 @@ +export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 +export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts new file mode 100644 index 00000000000..bf2e88a15a9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + clearDeadFlag: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { handleReconnectCredential } from '@/lib/credentials/draft-hooks' + +describe('handleReconnectCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits a reconnect with the credential current name instead of draft presentation', async () => { + queueTableRows(schemaMock.credential, [ + { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, + ]) + queueTableRows(schemaMock.credential, []) + + await handleReconnectCredential({ + draft: { credentialId: 'credential-1' }, + newAccountId: 'account-new', + workspaceId: 'workspace-1', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'credential-1', + resourceName: 'Renamed Gmail', + description: 'Reconnected OAuth credential "Renamed Gmail" to a new account', + }) + ) + }) +}) diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index c1aed407dca..704a22c25fb 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -105,7 +105,7 @@ export async function handleCreateCredentialFromDraft(params: { * the dead flag. Callers treat that timestamp as proof the reconnect landed. */ export async function handleReconnectCredential(params: { - draft: { credentialId: string | null; workspaceId: string; displayName: string } + draft: { credentialId: string | null } newAccountId: string workspaceId: string userId: string @@ -115,7 +115,11 @@ export async function handleReconnectCredential(params: { if (!draft.credentialId) return const [existingCredential] = await db - .select({ id: schema.credential.id, accountId: schema.credential.accountId }) + .select({ + id: schema.credential.id, + accountId: schema.credential.accountId, + displayName: schema.credential.displayName, + }) .from(schema.credential) .where(eq(schema.credential.id, draft.credentialId)) .limit(1) @@ -125,6 +129,7 @@ export async function handleReconnectCredential(params: { } const oldAccountId = existingCredential.accountId + const displayName = existingCredential.displayName const accountChanged = oldAccountId !== newAccountId if (accountChanged) { @@ -171,10 +176,10 @@ export async function handleReconnectCredential(params: { action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, resourceId: draft.credentialId, - resourceName: draft.displayName, + resourceName: displayName, description: accountChanged - ? `Reconnected OAuth credential "${draft.displayName}" to a new account` - : `Reconnected OAuth credential "${draft.displayName}"`, + ? `Reconnected OAuth credential "${displayName}" to a new account` + : `Reconnected OAuth credential "${displayName}"`, metadata: { oldAccountId, newAccountId }, }) diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index 9eb88bc1953..ad7b55e162b 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -20,7 +20,10 @@ vi.mock('@/lib/credentials/draft-hooks', () => ({ handleReconnectCredential: mockHandleReconnectCredential, })) -import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + parseCredentialDraftIdFromCallbackUrl, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' function credentialDraft(id: string, workspaceId: string) { return { @@ -103,3 +106,20 @@ describe('processCredentialDraft', () => { expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) }) + +describe('parseCredentialDraftIdFromCallbackUrl', () => { + it('extracts the exact draft id from a valid callback URL', () => { + expect( + parseCredentialDraftIdFromCallbackUrl( + 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1' + ) + ).toBe('draft-1') + }) + + it('fails closed for malformed or non-string callback state', () => { + expect(() => parseCredentialDraftIdFromCallbackUrl({})).toThrow( + 'OAuth state callback URL must be a string' + ) + expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index 32b46d686a2..e136e482900 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -11,6 +11,15 @@ const logger = createLogger('CredentialDraftProcessor') export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' +/** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ +export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { + if (callbackUrl === undefined) return undefined + if (typeof callbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined +} + interface ProcessCredentialDraftParams { draftId?: string userId: string diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts index 28a3a15595f..61de958536d 100644 --- a/apps/sim/lib/oauth/shopify-state.test.ts +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const CLIENT_SECRET = 'shopify-client-secret' @@ -73,8 +74,11 @@ describe('Shopify OAuth state', () => { clientSecret: CLIENT_SECRET, }) - expect(() => parse(state, { now: new Date(issuedAt.getTime() + 10 * 60 * 1000 + 1) })).toThrow( - 'Shopify OAuth state is expired' + expect(parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS) })).toEqual( + {} ) + expect(() => + parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1) }) + ).toThrow('Shopify OAuth state is expired') }) }) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts index 23d5f29d761..3c10b1d5778 100644 --- a/apps/sim/lib/oauth/shopify-state.ts +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -1,9 +1,9 @@ import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' import { generateId } from '@sim/utils/id' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' const SHOPIFY_OAUTH_STATE_VERSION = 1 -const SHOPIFY_OAUTH_STATE_TTL_MS = 10 * 60 * 1000 interface ShopifyOAuthStatePayload { v: typeof SHOPIFY_OAUTH_STATE_VERSION @@ -94,7 +94,7 @@ export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { } const now = params.now?.getTime() ?? Date.now() - if (decoded.issuedAt > now || now - decoded.issuedAt > SHOPIFY_OAUTH_STATE_TTL_MS) { + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { throw new Error('Shopify OAuth state is expired') } From ae46594e5b899794988a235a019859a63427ab8f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 15:23:58 -0700 Subject: [PATCH 15/18] fix(credentials): fail closed without breaking auth --- .../api/auth/shopify/authorize/route.test.ts | 60 +++++++++++++++++++ .../app/api/auth/shopify/authorize/route.ts | 3 +- apps/sim/lib/auth/auth.ts | 44 +++++++------- .../lib/credentials/draft-processor.test.ts | 27 +++++++++ apps/sim/lib/credentials/draft-processor.ts | 23 +++++++ 5 files changed, 134 insertions(+), 23 deletions(-) create mode 100644 apps/sim/app/api/auth/shopify/authorize/route.test.ts diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts new file mode 100644 index 00000000000..8829fb01422 --- /dev/null +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/oauth/shopify-state', () => ({ + createShopifyOAuthState: () => 'signed-state', +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getScopesForService: () => ['read_products'], +})) + +import { GET } from '@/app/api/auth/shopify/authorize/route' + +describe('Shopify authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }, + }) + }) + + it('keeps the post-connect return URL for the full credential draft lifetime', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/shopify/authorize?shop=test-store.myshopify.com&returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected&draftId=draft-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=') + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + }) +}) diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 9ef6909e581..e49efe20c51 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -9,6 +9,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' import { getScopesForService } from '@/lib/oauth/utils' @@ -209,7 +210,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: 60 * 10, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: '/', }) } else { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 43fbdf2a980..7906d93707d 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,7 +97,7 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { - parseCredentialDraftIdFromCallbackUrl, + loadOAuthCredentialDraftBinding, processCredentialDraft, } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -526,33 +526,33 @@ export const auth = betterAuth({ } } - let credentialDraftId: string | undefined - try { - const oauthState = await getOAuthState() - credentialDraftId = parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL) - } catch (error) { + const credentialDraftBinding = await loadOAuthCredentialDraftBinding(() => + getOAuthState() + ) + if (credentialDraftBinding.status === 'unavailable') { logger.error('[account.create.after] Failed to read OAuth credential draft state', { userId: account.userId, providerId: account.providerId, - error, + error: credentialDraftBinding.error, }) - throw error } - try { - await processCredentialDraft({ - draftId: credentialDraftId, - userId: account.userId, - providerId: account.providerId, - accountId: account.id, - }) - } catch (error) { - logger.error('[account.create.after] Failed to process credential draft', { - userId: account.userId, - providerId: account.providerId, - error, - }) - if (credentialDraftId) throw error + if (credentialDraftBinding.status === 'available') { + try { + await processCredentialDraft({ + draftId: credentialDraftBinding.draftId, + userId: account.userId, + providerId: account.providerId, + accountId: account.id, + }) + } catch (error) { + logger.error('[account.create.after] Failed to process credential draft', { + userId: account.userId, + providerId: account.providerId, + error, + }) + if (credentialDraftBinding.draftId) throw error + } } try { diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index ad7b55e162b..2cf5c13014d 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -21,6 +21,7 @@ vi.mock('@/lib/credentials/draft-hooks', () => ({ })) import { + loadOAuthCredentialDraftBinding, parseCredentialDraftIdFromCallbackUrl, processCredentialDraft, } from '@/lib/credentials/draft-processor' @@ -123,3 +124,29 @@ describe('parseCredentialDraftIdFromCallbackUrl', () => { expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() }) }) + +describe('loadOAuthCredentialDraftBinding', () => { + it('returns the exact draft id when OAuth state is readable', async () => { + await expect( + loadOAuthCredentialDraftBinding(async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + ).resolves.toEqual({ status: 'available', draftId: 'draft-exact' }) + }) + + it('marks unreadable OAuth state unavailable instead of permitting legacy draft fallback', async () => { + const stateError = new Error('OAuth state is unavailable') + + await expect( + loadOAuthCredentialDraftBinding(async () => { + throw stateError + }) + ).resolves.toEqual({ status: 'unavailable', error: stateError }) + }) + + it('marks malformed callback state unavailable without throwing from the account hook', async () => { + const binding = await loadOAuthCredentialDraftBinding(async () => ({ callbackURL: null })) + + expect(binding.status).toBe('unavailable') + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index e136e482900..0b60ccac106 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -11,6 +11,14 @@ const logger = createLogger('CredentialDraftProcessor') export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' +interface OAuthStateWithCallbackUrl { + callbackURL?: unknown +} + +type OAuthCredentialDraftBinding = + | { status: 'available'; draftId?: string } + | { status: 'unavailable'; error: unknown } + /** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { if (callbackUrl === undefined) return undefined @@ -20,6 +28,21 @@ export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): str return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined } +/** Reads an exact draft binding without falling back when OAuth state is unavailable. */ +export async function loadOAuthCredentialDraftBinding( + loadOAuthState: () => Promise +): Promise { + try { + const oauthState = await loadOAuthState() + return { + status: 'available', + draftId: parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL), + } + } catch (error) { + return { status: 'unavailable', error } + } +} + interface ProcessCredentialDraftParams { draftId?: string userId: string From b41318329ea5d6dd75386503ed4b5c1e3b231d7e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 14 Aug 2026 17:13:05 -0700 Subject: [PATCH 16/18] fix(credentials): preserve migrated route behavior --- .../migrate-application-operation/SKILL.md | 41 +++++ .../commands/migrate-application-operation.md | 41 +++++ .../commands/migrate-application-operation.md | 41 +++++ .../api/auth/oauth2/authorize/route.test.ts | 25 +++ .../app/api/auth/oauth2/authorize/route.ts | 66 ++++---- .../oauth2/callback/shopify/route.test.ts | 48 +++++- .../api/auth/oauth2/callback/shopify/route.ts | 9 +- .../api/auth/shopify/authorize/route.test.ts | 28 +++- .../app/api/auth/shopify/authorize/route.ts | 24 ++- .../credentials/[id]/members/route.test.ts | 154 ++++++++++++++++++ .../app/api/credentials/[id]/members/route.ts | 9 +- apps/sim/app/api/credentials/route.test.ts | 51 +++++- apps/sim/app/api/credentials/route.ts | 10 +- .../microsoft-excel/microsoft-excel.ts | 2 +- apps/sim/hooks/queries/credentials.ts | 3 +- .../utils/fetch-workspace-credentials.ts | 17 +- apps/sim/hooks/use-oauth-return.ts | 5 +- apps/sim/lib/api/contracts/credentials.ts | 37 ++++- .../api/contracts/oauth-connections.test.ts | 12 ++ .../lib/api/contracts/oauth-connections.ts | 9 +- .../sim/lib/credentials/api/route-policies.ts | 31 ++++ .../application/authorized-user-use-case.ts | 79 ++++++--- .../application/credential-crud.ts | 27 +-- .../application/credential-members.ts | 4 +- .../application/oauth-accounts.test.ts | 84 ++++++++++ .../credentials/application/oauth-accounts.ts | 86 +++++++--- .../lib/credentials/application/operations.ts | 15 +- .../application/service-account.test.ts | 48 ++++++ .../application/service-account.ts | 43 +++-- apps/sim/lib/credentials/oauth-accounts.ts | 35 +++- apps/sim/lib/credentials/queries.ts | 40 +++++ apps/sim/lib/oauth/shopify-state.test.ts | 12 +- apps/sim/lib/oauth/shopify-state.ts | 11 +- findings.txt | 19 +++ scripts/check-api-validation-contracts.ts | 4 +- 35 files changed, 983 insertions(+), 187 deletions(-) create mode 100644 apps/sim/app/api/credentials/[id]/members/route.test.ts create mode 100644 apps/sim/lib/credentials/application/oauth-accounts.test.ts create mode 100644 findings.txt diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md index bc61333c358..d8048553e78 100644 --- a/.claude/commands/migrate-application-operation.md +++ b/.claude/commands/migrate-application-operation.md @@ -77,6 +77,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -269,6 +306,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md index 9fac674ca6f..0742f452523 100644 --- a/.cursor/commands/migrate-application-operation.md +++ b/.cursor/commands/migrate-application-operation.md @@ -73,6 +73,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -265,6 +302,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index c4a275a840d..4b4c07ebe22 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -4,6 +4,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' @@ -192,6 +193,30 @@ describe('OAuth2 authorize route', () => { expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') }) + it('keeps a reconnect workspace-role denial classified as workspace access', async () => { + mocks.createConnection.mockRejectedValue(new InsufficientWorkspacePermissionsError()) + + const response = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + }) + + it('redirects a draft launch infrastructure failure through the browser error contract', async () => { + mocks.launchConnection.mockRejectedValue(new Error('Database unavailable')) + + const response = await GET(request({ draftId: 'draft-1' })) + + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + }) + it('routes custom providers through the exact application draft', async () => { mocks.createConnection.mockResolvedValue({ providerId: 'trello', diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 7526b1b67e8..414ff227c2d 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -39,40 +39,40 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { draftId } = parsed.data.query let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query - let fromConnectionDraft = false - let connectionDraftId: string | undefined - if (draftId) { - try { - const { draft } = await launchCredentialConnection.execute({ - principal, - input: { draftId }, - request, - }) - providerId = draft.providerId - workspaceId = draft.workspaceId - credentialId = draft.credentialId ?? undefined - connectionDraftId = draft.id - fromConnectionDraft = true - } catch (error) { - if (!(error instanceof OrchestrationError)) throw error - logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + try { + let fromConnectionDraft = false + let connectionDraftId: string | undefined + if (draftId) { + try { + const { draft } = await launchCredentialConnection.execute({ + principal, + input: { draftId }, + request, + }) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + } } - } - if (!providerId || !workspaceId) { - throw new Error('Validated OAuth authorization request is missing its target') - } + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } - const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) - connectionCompleteUrl.searchParams.set('result', 'connected') - const callbackURL = fromConnectionDraft - ? connectionCompleteUrl.toString() - : requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` - try { if (!fromConnectionDraft) { try { const connection = await createCredentialConnection.execute({ @@ -90,7 +90,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (error instanceof CredentialConnectionProviderMismatchError) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) } - if (credentialId && error instanceof ForbiddenOperationError) { + if ( + credentialId && + error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' + ) { return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) } if (error instanceof OrchestrationError && error.code === 'not_found') { diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts index 664a50bc714..e9aa534852a 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -63,11 +63,13 @@ describe('Shopify OAuth callback', () => { mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) ) ) }) @@ -77,6 +79,7 @@ describe('Shopify OAuth callback', () => { userId: 'user-1', shopDomain: SHOP_DOMAIN, draftId: 'draft-from-state', + returnUrl: 'https://sim.test/oauth/credential-connected?result=connected', clientSecret: CLIENT_SECRET, }) @@ -94,4 +97,39 @@ describe('Shopify OAuth callback', () => { 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' ) }) + + it('keeps overlapping flows bound to their own return destinations', async () => { + const firstState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-first', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const secondState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-second', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + const firstResponse = await GET(callbackRequest(firstState)) + const secondResponse = await GET(callbackRequest(secondState)) + + expect(firstResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=first&shopify_connected=true' + ) + expect(secondResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=second&shopify_connected=true' + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ draftId: 'draft-first' }) + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ draftId: 'draft-second' }) + ) + }) }) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index bf2907856d2..8447e56d48d 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -103,7 +103,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } - const { draftId } = parseShopifyOAuthState({ + const { draftId, returnUrl } = parseShopifyOAuthState({ state, userId: session.user.id, shopDomain, @@ -154,9 +154,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { signal: request.signal, }) - const returnUrlCookie = request.cookies.get('shopify_return_url')?.value - const redirectUrl = - returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + if (returnUrl && !isSameOrigin(returnUrl)) { + throw new Error('Shopify OAuth state contains an invalid return URL') + } + const redirectUrl = returnUrl ?? `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts index 8829fb01422..2e4ea50a32a 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.test.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ getSession: vi.fn(), requireConfiguredOAuthClient: vi.fn(), + createShopifyOAuthState: vi.fn(), })) vi.mock('@/lib/auth', () => ({ @@ -22,7 +23,7 @@ vi.mock('@/lib/core/utils/urls', () => ({ })) vi.mock('@/lib/oauth/shopify-state', () => ({ - createShopifyOAuthState: () => 'signed-state', + createShopifyOAuthState: mocks.createShopifyOAuthState, })) vi.mock('@/lib/oauth/utils', () => ({ @@ -41,9 +42,10 @@ describe('Shopify authorize route', () => { SHOPIFY_CLIENT_SECRET: 'shopify-secret', }, }) + mocks.createShopifyOAuthState.mockReturnValue('signed-state') }) - it('keeps the post-connect return URL for the full credential draft lifetime', async () => { + it('binds the post-connect return URL to the signed flow state', async () => { const request = createMockRequest( 'GET', undefined, @@ -54,7 +56,25 @@ describe('Shopify authorize route', () => { const response = await GET(request) expect(response.status).toBe(307) - expect(response.headers.get('set-cookie')).toContain('shopify_return_url=') - expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + expect(mocks.createShopifyOAuthState).toHaveBeenCalledWith({ + userId: 'user-1', + shopDomain: 'test-store.myshopify.com', + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + clientSecret: 'shopify-secret', + }) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=;') + }) + + it('escapes a user-controlled draft id before embedding it in inline script', async () => { + const url = new URL('https://sim.test/api/auth/shopify/authorize') + url.searchParams.set('draftId', '') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html.match(/