From 4ac374103f1d44e10e2a90b46276dad3d057e2b3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:24:44 -0500 Subject: [PATCH] fix(connections): repair Asana MCP and add shared-app sign-in (#14756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connections let agents use provider tools through the permission gateway. > - Asana provides an official remote MCP server, but its v2 server requires a registered MCP OAuth app. > - Setup can discover retired v1 endpoints and send a callback that differs from the displayed URL. > - This pull request repairs custom app setup and adds sign-in through Paperclip's shared app. > - Users can choose their own app without enrolling with Paperclip Cloud. > - Agents can use Asana tools after the user connects their account and sets action permissions. ## Linked Issues or Issue Description Related: #14739 supplies the personal credential repair used by resumed Asana setup. No duplicate Asana authentication PR was found. **What happened?** Asana setup failed even with a user-created app. Root discovery metadata still points at v1. MCP v2 uses the Asana OAuth issuer and requires an MCP app with a client secret. Local setup also displayed a localhost callback while an Origin header could make authorization use a numeric loopback callback. **Expected behavior** Sign in with Paperclip's app when its broker profile is available. Keep custom MCP app setup available without Cloud enrollment. Use the correct issuer, callback, client credentials, and resource throughout setup. **Steps to reproduce** 1. Open Asana in the connection catalog. 2. Supply an Asana MCP app's client ID and secret. 3. Start OAuth on a local instance opened with a numeric loopback address, or resume a draft that cached v1 metadata. 4. Observe the wrong discovery endpoint or callback mismatch. **Paperclip version or commit** Reproduced from b54b2dc35c1f41d367ff4d94615ebfd38bbbdad0. Rebased onto master at `0829d94af` after the single-screen setup change in #14811. **Deployment mode** Local development from source. The managed path also supports enrolled self-hosted instances. ## What Changed - Add the `asana.mcp` managed profile and the default Sign in with Asana method. - Use Asana's reviewed v2 protected-resource metadata before cached endpoints. - Require a custom MCP app's client secret and retain saved credentials during setup or reconnect. Repair only the known Asana v1 issuer/resource binding, retaining company and callback checks. - Expose a boolean for the acting user's saved client secret. The form offers secret reuse only when that user has an active grant with the required reference. - Let users select their own app from the enrollment and shared-app-unavailable screens, or from Advanced on the single-screen setup page. - Canonicalize HTTP loopback callbacks even when the request includes an Origin header. - Extend signed broker claims and provider URL validation for Asana. Require refresh credentials on managed authorization. - Document setup, distribution, and shared-app rollout requirements. - Resolve permission-profile name collisions when finishing another account. The live staging test found this after renaming the first Asana connection; OAuth succeeded but profile finalization failed. ## Verification - Final live staging proof used app commit `c6053157c4e42ac017727117b754ae77fa5c45fa` and the real Cloud broker at `767b63835170f664542afd0df99a76615e204b62`. In the embedded browser, default shared sign-in required no client credentials, returned through the central Cloud callback to the tenant, and discovered 39 actions. Get me succeeded through the gateway as the selected QA agent (2.1 seconds). The custom-app connection also returned a real result on this final build (0.9 seconds). - Retried the shared draft that failed during the first staging test. It completed after the profile-name fix, retained the selected agent, and kept the existing custom connection intact. Two database regressions reproduced the collision before the fix and passed afterward. The updated transaction rollback test also passes. - Shared reconnect returned to the same staging connection with 39 actions. Earlier staging checks verified the custom-app fallback when the shared profile was unavailable, saved-secret reuse on reconnect, and Off blocking the action test. Allowed was restored after that check. - Local live-provider checks also repaired a saved Asana v1 issuer/resource binding without reentering the secret and verified that numeric-loopback setup uses the displayed localhost callback. Expiring the local managed access-token timestamp triggered a real Asana refresh and a successful Get me call. These early local broker tests used enrollment/authentication and storage fixtures; the final staging proof used deployed Cloud identity and persistent storage. - The new production app is registered and configured, but production sign-in has not been deployed or verified. Live provider revocation was not run because the existing staging test app is shared with other connections. - After rebasing onto the single-screen setup flow, full `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass. Focused verification passes 365 service and 36 broker-client tests. Broader checks pass all 833 shared-package tests and all 530 connector-page tests. The shared suite uses `TMPDIR=/private/tmp` to avoid macOS temporary-directory symlinks in its canonical-path tests. The UI tests verify the shared-app default and switching to a custom app with its required client secret. - Embedded-browser smoke on the current rebased build verified the shared sign-in default, Advanced → custom app (client ID and secret required), and switching back to Paperclip. Both existing Asana connections remained connected after restart. No new provider authorization was performed during this smoke. - The full local `pnpm test:run` was interrupted when the execution session restarted. Before interruption, it reported one runtime-slot restart test failure. That test passed on an isolated retry after clearing two unused PostgreSQL shared-memory segments. The full local suite did not complete; CI must pass on the current head before merge. - All 52 CI and security checks pass on `9318fd4e9b616cdc3de12f40cdb9bd32d865af4c` (CI run `36882064080`), including all eight browser shards, nine serialized-server shards, build, typecheck, and canary dry run. Two optional Storybook checks were skipped. Greptile review 4 reports 5/5 on this exact commit, with all review threads resolved. Its updated summary identifies the current SHA; this comment-triggered review did not publish a separate GitHub check run. - Provider revocation is unit-tested in the companion broker. Live provider revocation was not run because the existing test app is shared with other connections. ## Risks - The shared Paperclip Asana MCP app has been registered with its production callback and Any workspace distribution. Its secret is provisioned in the production secret store, and the runtime client ID and secret reference are configured. The production profile is enabled in the saved deployment configuration. The companion broker has merged and passed staging deployment; production sign-in still requires a production deployment and live verification. Custom setup remains available. - Asana MCP uses the provider's fixed `default` grant. Paperclip action policies limit agent tool use; they do not narrow provider consent. - The callback correction affects HTTP loopback OAuth flows. Public HTTPS callbacks retain their existing behavior. - Reviewed discovery URLs now override stale cached endpoints. Tests cover the Asana v1-to-v2 repair. - No schema migration. Connection removal retains the existing local-only revocation behavior. ## Model Used OpenAI GPT-6 through Codex, with code execution, browser testing, and GitHub tooling. The exact model variant and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/ASANA.md | 56 +++++ doc/connections/README.md | 2 +- .../tool-method-permission-reviews.json | 21 +- packages/shared/src/app-definitions.test.ts | 13 +- .../shared/src/app-definitions/asana.json | 44 +++- packages/shared/src/asana-connectors.ts | 10 + .../shared/src/connection-setup-state.test.ts | 14 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/app-definition.ts | 2 +- packages/shared/src/types/tool-access.ts | 2 + .../shared/src/validators/app-definition.ts | 2 +- scripts/ingest-app-definitions.mjs | 25 +++ .../src/__tests__/tool-access-service.test.ts | 196 ++++++++++++++++-- server/src/routes/tool-access.ts | 2 +- .../paperclip-cloud-connector.test.ts | 21 ++ .../src/services/paperclip-cloud-connector.ts | 13 +- server/src/services/tool-access.ts | 98 ++++++++- .../connections/ConnectionSetupFlow.tsx | 89 +++++--- ui/src/pages/apps/AppsConnect.test.tsx | 83 ++++++-- ui/src/pages/apps/app-connect-policy.test.ts | 11 +- 20 files changed, 596 insertions(+), 109 deletions(-) create mode 100644 doc/connections/ASANA.md create mode 100644 packages/shared/src/asana-connectors.ts diff --git a/doc/connections/ASANA.md b/doc/connections/ASANA.md new file mode 100644 index 00000000000..117b7d0394b --- /dev/null +++ b/doc/connections/ASANA.md @@ -0,0 +1,56 @@ +# Asana + +Paperclip uses [Asana's official remote MCP server](https://developers.asana.com/docs/integrating-with-asanas-mcp-server) +at `https://mcp.asana.com/v2/mcp`. The default connection uses Paperclip's +shared OAuth app through the Cloud connector broker. An enrolled self-hosted +instance can use that same app. **Use your own Asana OAuth app** is available +without enrolling the instance, including when the shared app is unavailable. + +## Your own app + +1. Open [Asana My apps](https://app.asana.com/0/my-apps) and create an + **Asana MCP** app. An API app or personal access token will not authorize MCP. +2. Under OAuth, copy the exact callback displayed in Paperclip into Redirect URLs. + For local development Paperclip uses `localhost`, even if opened at `127.0.0.1`. + Public deployments should configure their canonical HTTPS URL. +3. Under Manage distribution, select the workspaces that can use the app and save. +4. Enter the client ID and client secret in Paperclip and continue to Asana. + +MCP v2 still requires OAuth and a preregistered client; it does not offer dynamic +client registration. Asana MCP currently grants one fixed `default` scope. +Paperclip's action permissions govern which discovered tools an agent can use. +The client secret is encrypted in the local credential vault and sent only to +the token endpoint. Resuming setup with the same client ID preserves the secret; +changing the client ID requires the matching new secret. + +## Discovery and lifecycle + +Asana's live v2 resource challenge points to +`https://mcp.asana.com/.well-known/oauth-protected-resource/v2`. The root metadata +still describes the retired v1 issuer. The curated method pins the v2 discovery +URL and re-resolves it for saved connections, repairing drafts that cached v1. +Saved manual-client bindings from that exact v1 issuer/resource are repaired for +Asana v2 while preserving company and callback checks. Other binding changes +still require new credentials. Secret reuse is shown only when the requesting +user has an active grant with a saved client-secret reference. +Authorization and token exchange use `https://app.asana.com/-/oauth_authorize` +and `https://app.asana.com/-/oauth_token`, with PKCE S256. + +The managed `asana.mcp` profile uses the existing signed Cloud connector protocol, +including instance-bound encrypted token delivery and refresh. Availability is +advertised only after the broker has its MCP app credentials and profile enabled. +The shared app's provider secret is held by the broker, never distributed to +instances. Rolling out the shared option requires the companion broker support, +app registration, allowed distribution, and provider configuration. + +## Verify + +Test both managed and custom sign-in in the browser. Confirm that callback and +workspace selection succeed, tools load, and a read-only tool such as **Get me** +works through **Test** as an installed agent. Also verify canceled/interrupted +setup can resume, and that refresh works after token expiration. Never place +provider tokens or client secrets in screenshots or test fixtures. + +Also connect a second account after renaming the first connection. Both custom +and shared app connections must finish with separate permission profiles, even +when the first profile still uses the provider's original name. diff --git a/doc/connections/README.md b/doc/connections/README.md index ac7196cd2bf..7e1bfe8f051 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -13,7 +13,7 @@ Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). Long-term memory: [Experimental memory connectors](./MEMORY.md). Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), -[Gmail](./GMAIL.md), [PostHog](./POSTHOG.md), +[Gmail](./GMAIL.md), [Asana](./ASANA.md), [PostHog](./POSTHOG.md), [AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody: [Vercel Connect](./VERCEL-CONNECT.md). diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index 162bc2b09cc..2ef0bc115fc 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -56,6 +56,23 @@ "reviewedAt": "2026-09-30", "liveProof": "not-run" }, + { + "app": "asana", + "method": "managed", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "default" + ], + "capability": "write", + "supportedActions": "Create/update tasks and projects authorized by the account.", + "evidence": [ + "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" + ], + "reviewedAt": "2026-09-30", + "liveProof": "Real OAuth, 39 MCP actions, Get me, and forced-expiry refresh verified with a local broker fixture on 2026-09-30." + }, { "app": "asana", "method": "mcp-own-oauth", @@ -68,10 +85,10 @@ "supportedActions": "Create/update tasks and projects authorized by the account.", "evidence": [ "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", - "https://mcp.asana.com/.well-known/oauth-protected-resource" + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" ], "reviewedAt": "2026-09-30", - "liveProof": "not-run" + "liveProof": "OAuth, 39 MCP actions, and Get me verified in the embedded browser on 2026-09-30." }, { "app": "beehiiv", diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index adce8e88f1f..da32aedf637 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -572,10 +572,15 @@ describe("AppDefinition catalog", () => { (field) => field.key === "readOnly", )?.defaultValue, ).toBe(false); - // Asana and Linear both advertise dynamic client registration and issue - // clients on request (verified live 2026-09-28), so neither needs an - // operator-registered OAuth app. "customer" stays as the manual fallback. - expect(method("asana")?.ownershipModes).toEqual(["dcr", "customer"]); + expect(method("asana")).toMatchObject({ + key: "managed", ownershipModes: ["platform_shared"], connectorProfile: "asana.mcp", + defaults: { serverUrl: "https://mcp.asana.com/v2/mcp", scopesHint: ["default"] }, + }); + expect(APP_DEFINITIONS.find((app) => app.slug === "asana")?.methods[1]).toMatchObject({ + key: "mcp-own-oauth", ownershipModes: ["customer"], oauthClientSecretRequired: true, + defaults: { discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2" }, + consoleLinks: { register: "https://app.asana.com/0/my-apps" }, + }); expect(method("linear")?.ownershipModes).toEqual(["dcr", "customer"]); expect(method("zapier")).toMatchObject({ key: "generated-url", diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json index ecda6f4b603..4cc2859043a 100644 --- a/packages/shared/src/app-definitions/asana.json +++ b/packages/shared/src/app-definitions/asana.json @@ -17,27 +17,57 @@ "redirectConstraints": "https-or-loopback-http", "methods": [ { - "key": "mcp-own-oauth", + "key": "managed", "transport": "mcp_remote", "auth": "oauth", "ownershipModes": [ - "dcr", - "customer" + "platform_shared" ], - "whenToUse": "Sign in with Asana. Paperclip registers its own OAuth client automatically.", + "whenToUse": "Connect your Asana account with Paperclip's app.", "defaults": { "serverUrl": "https://mcp.asana.com/v2/mcp", "scopesHint": [ "default" ] }, - "guidanceMd": "Connect Asana in the browser. Paperclip registers its own OAuth client with Asana's MCP server, so a local Paperclip needs no developer-console setup. Asana only accepts localhost callbacks, so a hosted deployment falls back to entering your own OAuth app.", + "guidanceMd": "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.", "riskTier": "S3", "label": "Sign in with Asana", "consoleLinks": { - "register": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", "docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server" - } + }, + "warnings": [], + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "asana.mcp", + "grantKinds": [ + "user", + "agent" + ] + }, + { + "key": "mcp-own-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.", + "defaults": { + "serverUrl": "https://mcp.asana.com/v2/mcp", + "discoveryUrl": "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + "scopesHint": [ + "default" + ] + }, + "guidanceMd": "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.", + "riskTier": "S3", + "label": "Use your own OAuth app", + "consoleLinks": { + "register": "https://app.asana.com/0/my-apps", + "docs": "https://developers.asana.com/docs/integrating-with-asanas-mcp-server" + }, + "warnings": [], + "oauthClientSecretRequired": true } ] } diff --git a/packages/shared/src/asana-connectors.ts b/packages/shared/src/asana-connectors.ts new file mode 100644 index 00000000000..6a585a7cdd6 --- /dev/null +++ b/packages/shared/src/asana-connectors.ts @@ -0,0 +1,10 @@ +export const ASANA_CONNECTOR_PROFILE_ID = "asana.mcp" as const; +export type AsanaConnectorProfileId = typeof ASANA_CONNECTOR_PROFILE_ID; + +export const ASANA_MCP_URL = "https://mcp.asana.com/v2/mcp"; +export const ASANA_MCP_DISCOVERY_URL = "https://mcp.asana.com/.well-known/oauth-protected-resource/v2"; +export const ASANA_CONNECTOR_SCOPES = ["default"] as const; + +export function isAsanaConnectorProfileId(value: string): value is AsanaConnectorProfileId { + return value === ASANA_CONNECTOR_PROFILE_ID; +} diff --git a/packages/shared/src/connection-setup-state.test.ts b/packages/shared/src/connection-setup-state.test.ts index e4cc83af584..252f1a1627b 100644 --- a/packages/shared/src/connection-setup-state.test.ts +++ b/packages/shared/src/connection-setup-state.test.ts @@ -17,22 +17,22 @@ import { */ describe("connectionSetupStateForApp", () => { it("puts one-click OAuth connectors in the authorize state", () => { - for (const slug of ["notion", "linear", "sentry", "stripe", "jira", "asana"]) { + for (const slug of ["notion", "linear", "sentry", "stripe", "jira"]) { expect(connectionSetupStateForApp(getConnectableAppDefinition(slug)), slug).toBe("authorize"); } }); - it("follows the instance's ownership availability rather than the catalog order", () => { - // Google and GitHub publish a Paperclip-managed method, but it is + it.each(["gmail", "asana"])("follows %s ownership availability rather than the catalog order", (slug) => { + // These providers publish a Paperclip-managed method, but it is // `platform_shared` and therefore unavailable until an operator configures // the cloud connector. The state has to reflect what this instance can // actually do, or the card promises one click and the screen shows a form. - const gmail = getConnectableAppDefinition("gmail")!; - expect(connectionSetupStateForApp(gmail)).toBe("register"); + const app = getConnectableAppDefinition(slug)!; + expect(connectionSetupStateForApp(app)).toBe("register"); expect( connectionSetupStateForApp({ - ...gmail, - ownershipAvailability: { ...gmail.ownershipAvailability, platform_shared: true }, + ...app, + ownershipAvailability: { ...app.ownershipAvailability, platform_shared: true }, }), ).toBe("authorize"); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92466095b9e..4cd4afc96e7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -343,6 +343,7 @@ export { export { APP_DEFINITIONS } from "./app-definitions.generated.js"; export * from "./google-workspace-connectors.js"; export * from "./github-connectors.js"; +export * from "./asana-connectors.js"; export { BLOCKED_MCP_PROVIDERS, SELF_SERVE_MCP_CANDIDATES, diff --git a/packages/shared/src/types/app-definition.ts b/packages/shared/src/types/app-definition.ts index b7b532a0536..de6cc3d98fe 100644 --- a/packages/shared/src/types/app-definition.ts +++ b/packages/shared/src/types/app-definition.ts @@ -2,7 +2,7 @@ import type { ConnectionGrantKind, ToolConnectionOwnership, ToolConnectionPurpos export type AppCategory = "ai"|"analytics"|"commerce"|"communication"|"content"|"data"|"developer"|"productivity"|"other"; export type OAuthRedirectConstraints = "https-or-loopback-http"; export interface FieldDef { key:string; label:string; type:"text"|"password"|"textarea"|"datetime"|"select"|"checkbox"; required?:boolean; advanced?:boolean; hidden?:boolean; placeholder?:string; helperMd?:string; secret?:boolean; prefix?:string; defaultValue?:string|boolean; validation?:{pattern?:string;maxLength?:number}; options?:Array<{value:string;label:string}>; transport?:{location:"query"|"header";name:string;format?:"string"|"csv"|"boolean";omitFalse?:boolean} } -export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } +export interface ConnectionMethodDef { ai?: import("../ai-connections.js").AiConnectionMetadata; key:string; label?:string; purpose?:ToolConnectionPurpose; provider?:"slack"|"github"|"discord"|"microsoft-teams"|"telegram"|"agentmail"|"imessage-photon"; transport:ToolConnectionTransport; auth:"oauth"|"api_key"|"none"; oauthStrategy?:"paperclip_cloud_connector"|"paperclip_id_connector"; connectorProfile?:string; oauthClientSecretRequired?:boolean; capabilityProfile?:{key:string;label:string;description?:string}; grantKinds?:ConnectionGrantKind[]; ownershipModes:ToolConnectionOwnership[]; whenToUse:string; defaults?:{serverUrl?:string;serverUrlTemplate?:string;discoveryUrl?:string|null;serviceHost?:string;templateKey?:string;authorizationEndpoint?:string;tokenEndpoint?:string;metadataUrl?:string;scopesHint?:string[];oauthAuthorizationParams?:{access_type?:"offline";prompt?:"consent"};toolArgumentDefaults?:Record}; tenantFields?:FieldDef[]; extensionFields?:FieldDef[]; configRequirements?:{atLeastOneOf?:string[]}; credentialFields?:FieldDef[]; keyPlacement?:{location:"header"|"query"|"body_json"|"env";name:string;prefix?:string|null}; credentialSources?:{vercelConnect?:{services:string[];principalModes:VercelConnectPrincipalMode[];scopes:string[];header:{name:string;prefix?:string|null}}}; guidanceMd:string; consoleLinks?:{register?:string;keys?:string;settings?:string;docs?:string}; warnings?:string[]; variants?:Array<{key:string;label:string;whenToUse:string;tenantFields?:FieldDef[]}>; riskTier:"S1"|"S2"|"S3"|"S4"; requiredResourceFilters?:string[] } export interface AppDefinition { schemaVersion:1; slug:string; name:string; description:string; categories:AppCategory[]; featured?:boolean; branding:{logoUrl:string;darkLogoUrl?:string;backgroundColor?:string;accentColor?:string}; urlPatterns:string[]; docsUrl?:string; setupPrerequisite?:{title:string;description:string;steps?:string[];actionLabel:string;actionUrl:string}; redirectConstraints?:OAuthRedirectConstraints; methods:ConnectionMethodDef[]; suggestable?:boolean; availability?:{available:boolean;reason?:string;robotEmail?:string}; ownershipAvailability?:Partial> } export type SelfServeMcpAuthMode = diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index dfc05e91da2..5abb3a64f66 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -182,6 +182,8 @@ export interface ToolConnection { transportConfig: Record; config?: Record; credentialSecretRefs: ToolCredentialSecretRef[]; + /** Saved client secret for the requesting user, or the shared connection. Never includes secret material. */ + hasSavedOAuthClientSecret?: boolean; credentialRefs?: McpConnectionCredentialRef[]; healthStatus: ToolConnectionHealthStatus; /** Managed GitHub grant state; transient health failures do not require sign-in. */ diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index 9be04386dad..d55b839e1f8 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -6,6 +6,6 @@ const appBrandAssetUrlSchema=z.string().refine((value)=>{ try{return new URL(value).protocol==="https:";}catch{return false;} },{message:"Brand assets must be HTTPS URLs or local /brands/apps SVG/PNG paths"}); const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),advanced:z.boolean().optional(),hidden:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional(),defaultValue:z.union([z.string(),z.boolean()]).optional(),validation:z.object({pattern:z.string().optional(),maxLength:z.number().int().positive().optional()}).optional(),options:z.array(z.object({value:z.string(),label:z.string()})).optional(),transport:z.object({location:z.enum(["query","header"]),name:z.string().min(1),format:z.enum(["string","csv","boolean"]).optional(),omitFalse:z.boolean().optional()}).optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]});if(v.type==="select"&&(!v.options||v.options.length===0))c.addIssue({code:"custom",message:"Select fields need options",path:["options"]});if(v.hidden&&v.defaultValue===undefined)c.addIssue({code:"custom",message:"Hidden fields need defaults",path:["defaultValue"]})}); -export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); +export const connectionMethodDefSchema=z.object({key:z.string().min(1),label:z.string().min(1).optional(),purpose:z.union([toolConnectionPurposeSchema,z.literal("ai")]).optional(),provider:z.enum(["slack","github","discord","microsoft-teams","telegram","agentmail","imessage-photon"]).optional(),transport:z.union([toolConnectionTransportSchema,z.literal("runtime_auth")]),ai:aiConnectionMetadataSchema.optional(),auth:z.enum(["oauth","api_key","none"]),oauthStrategy:z.enum(["paperclip_cloud_connector","paperclip_id_connector"]).optional(),connectorProfile:z.string().regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/).optional(),oauthClientSecretRequired:z.boolean().optional(),capabilityProfile:z.object({key:z.string().min(1),label:z.string().min(1),description:z.string().min(1).optional()}).optional(),grantKinds:z.array(connectionGrantKindSchema).min(1).optional(),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),serverUrlTemplate:z.string().regex(/^https:\/\//).optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional(),oauthAuthorizationParams:z.object({access_type:z.literal("offline").optional(),prompt:z.literal("consent").optional()}).optional(),toolArgumentDefaults:z.record(z.string(),z.unknown()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),configRequirements:z.object({atLeastOneOf:z.array(z.string().min(1)).min(1).optional()}).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),credentialSources:z.object({vercelConnect:z.object({services:z.array(z.string().min(1)).min(1),principalModes:z.array(z.enum(["app","user"])).min(1),scopes:z.array(z.string().min(1)).min(1),header:z.object({name:z.string().min(1),prefix:z.string().nullable().optional()})}).optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if((v.transport==="runtime_auth")!==Boolean(v.ai))c.addIssue({code:"custom",message:"Runtime authentication requires AI metadata and AI metadata requires runtime_auth",path:["ai"]});if(v.ai&&!AI_CONNECTION_CAPABILITIES[v.ai.provider].methods[v.ai.method])c.addIssue({code:"custom",message:"Unsupported AI sign-in method",path:["ai","method"]});if(v.ai&&v.auth!==(v.ai.method==="subscription"?"oauth":"api_key"))c.addIssue({code:"custom",message:"AI sign-in method must match authentication",path:["auth"]});const purpose=v.purpose??"tool";if((purpose==="ai")!==(v.transport==="runtime_auth"))c.addIssue({code:"custom",message:"AI methods require runtime_auth and runtime_auth requires AI purpose",path:["purpose"]});if(v.transport==="chat_sdk"&&purpose!=="channel")c.addIssue({code:"custom",message:"Chat SDK methods must be channel connections",path:["purpose"]});if(purpose==="channel"&&v.transport!=="chat_sdk"&&!(v.provider==="agentmail"&&v.transport==="rest_api"))c.addIssue({code:"custom",message:"Channel connections must use the Chat SDK transport",path:["transport"]});if(purpose==="channel"&&!v.provider)c.addIssue({code:"custom",message:"Channel connections require a chat provider",path:["provider"]});if(v.auth==="api_key"&&!v.keyPlacement&&purpose!=="channel")c.addIssue({code:"custom",message:"API-key tool methods require keyPlacement",path:["keyPlacement"]});if(v.oauthStrategy&&v.auth!=="oauth")c.addIssue({code:"custom",message:"OAuth strategies require OAuth auth",path:["oauthStrategy"]});if(v.oauthStrategy&&!v.connectorProfile)c.addIssue({code:"custom",message:"Paperclip Cloud connector methods require connectorProfile",path:["connectorProfile"]});if(v.connectorProfile&&!v.oauthStrategy)c.addIssue({code:"custom",message:"connectorProfile requires a Paperclip Cloud OAuth strategy",path:["connectorProfile"]});if(v.credentialSources?.vercelConnect&&(v.transport!=="mcp_remote"||v.auth==="none"))c.addIssue({code:"custom",message:"Vercel Connect requires an authenticated remote MCP method",path:["credentialSources","vercelConnect"]});const keys=new Set([...(v.tenantFields??[]),...(v.extensionFields??[])].map((entry)=>entry.key));for(const key of v.configRequirements?.atLeastOneOf??[])if(!keys.has(key))c.addIssue({code:"custom",message:"Config requirement references an unknown field",path:["configRequirements","atLeastOneOf"]});if(v.defaults?.serverUrl&&v.defaults.serverUrlTemplate)c.addIssue({code:"custom",message:"Use either serverUrl or serverUrlTemplate",path:["defaults"]});for(const placeholder of v.defaults?.serverUrlTemplate?.matchAll(/\{([a-zA-Z0-9_-]+)\}/g)??[])if(!keys.has(placeholder[1]))c.addIssue({code:"custom",message:"Server URL template references an unknown field",path:["defaults","serverUrlTemplate"]})}); export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:appBrandAssetUrlSchema,darkLogoUrl:appBrandAssetUrlSchema.optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),setupPrerequisite:z.object({title:z.string().min(1),description:z.string().min(1),steps:z.array(z.string().min(1)).min(1).optional(),actionLabel:z.string().min(1),actionUrl:z.string().url()} ).optional(),redirectConstraints:z.enum(["https-or-loopback-http"]).optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 7902a48008e..1ecfeb2e19e 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1095,6 +1095,31 @@ const apiKeyMethodFor = ( ); }; const specialMethodsFor = (entry) => { + if (entry.slug === "asana") return [ + oauthMethodFor(entry, "managed", entry.serverUrl, { + label: "Sign in with Asana", + ownershipModes: ["platform_shared"], + oauthStrategy: "paperclip_cloud_connector", + connectorProfile: "asana.mcp", + grantKinds: ["user", "agent"], + defaults: { serverUrl: entry.serverUrl, scopesHint: ["default"] }, + guidanceMd: "Sign in to Asana with Paperclip. Asana gives this connection access to the workspaces available to your account.", + whenToUse: "Connect your Asana account with Paperclip's app.", + warnings: [], + }), + { + ...customerOAuthMethodFor(entry), + defaults: { + serverUrl: entry.serverUrl, + discoveryUrl: "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + scopesHint: ["default"], + }, + oauthClientSecretRequired: true, + guidanceMd: "Create an MCP app in Asana, then add the callback URL below under OAuth. Under Manage distribution, select your workspace and save. API apps do not work with Asana MCP.", + consoleLinks: { register: "https://app.asana.com/0/my-apps", docs: entry.docsUrl }, + warnings: [], + }, + ]; if (entry.slug === "mem0" || entry.slug === "honcho") return [ apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`, diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7fc5d900106..6aee6ee0ecf 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -10311,6 +10311,7 @@ describeEmbeddedPostgres("tool access service", () => { ])( "starts and completes OAuth with the same redirect URI at %s", async (origin) => { + const callbackOrigin = origin.replace("http://127.0.0.1:", "http://localhost:"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", @@ -10349,7 +10350,7 @@ describeEmbeddedPostgres("tool access service", () => { /^[A-Za-z0-9_-]{43}$/, ); expect(startUrl.searchParams.get("redirect_uri")).toBe( - `${origin}/api/tools/oauth/callback`, + `${callbackOrigin}/api/tools/oauth/callback`, ); const state = startUrl.searchParams.get("state"); expect(state).toBeTruthy(); @@ -10375,7 +10376,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(body.get("client_secret")).toBe("slack-client-secret"); expect(body.get("code_verifier")).toBeTruthy(); expect(body.get("redirect_uri")).toBe( - `${origin}/api/tools/oauth/callback`, + `${callbackOrigin}/api/tools/oauth/callback`, ); return { ok: true, @@ -10417,7 +10418,7 @@ describeEmbeddedPostgres("tool access service", () => { const callbackRes = await request(app) .get("/api/tools/oauth/callback") - .set("Host", new URL(origin).host) + .set("Host", new URL(callbackOrigin).host) .query({ state, code: "oauth-code" }); expect(callbackRes.status).toBe(200); @@ -10462,7 +10463,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(redirectState).toBeTruthy(); const redirectCallbackRes = await request(app) .get("/api/tools/oauth/callback") - .set("Host", new URL(origin).host) + .set("Host", new URL(callbackOrigin).host) .set("Accept", "text/html") .query({ state: redirectState, code: "oauth-code" }); @@ -10497,6 +10498,7 @@ describeEmbeddedPostgres("tool access service", () => { const connectRes = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) .set("Host", "127.0.0.1:3200") + .set("Origin", "http://127.0.0.1:3200") .send({ galleryKey: "slack", name: "Loopback Slack workspace" }); expect(connectRes.status).toBe(201); @@ -11616,11 +11618,89 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("uses Asana v2 metadata instead of stale v1 endpoints for a saved custom app", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + await db.update(toolConnections).set({ config: { + ...connection.config, + oauth: { ...connection.config.oauth as Record, + issuer: "https://mcp.asana.com", authorizationUrl: "https://mcp.asana.com/authorize", + tokenUrl: "https://mcp.asana.com/token", resource: "https://mcp.asana.com", + clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com", + clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback" }, + } }).where(eq(toolConnections.id, connected.connectionId)); + const calls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + calls.push(String(url)); + if (String(url) === "https://mcp.asana.com/.well-known/oauth-protected-resource/v2") { + return mcpHttpResponse({ resource: "https://mcp.asana.com/v2/mcp", + authorization_servers: ["https://app.asana.com"], scopes_supported: ["default"] }); + } + if (String(url) === "https://app.asana.com/.well-known/oauth-authorization-server") { + return mcpHttpResponse({ issuer: "https://app.asana.com", + authorization_endpoint: "https://app.asana.com/-/oauth_authorize", + token_endpoint: "https://app.asana.com/-/oauth_token", + token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], + code_challenge_methods_supported: ["S256"] }); + } + throw new Error(`unexpected fetch ${url}`); + }); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + }); + const authorization = new URL(started.authorizationUrl); + expect(authorization.origin + authorization.pathname).toBe("https://app.asana.com/-/oauth_authorize"); + expect(authorization.searchParams.get("resource")).toBe("https://mcp.asana.com/v2/mcp"); + expect(authorization.searchParams.get("scope")).toBe("default"); + expect(authorization.searchParams.get("client_id")).toBe("asana-client"); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + expect(calls).toEqual([ + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2", + "https://app.asana.com/.well-known/oauth-authorization-server", + ]); + const migrated = await service.getConnection(connected.connectionId, company.id); + expect(migrated.config?.oauth).toMatchObject({ + clientIssuer: "https://app.asana.com", clientResource: "https://mcp.asana.com/v2/mcp", + }); + expect(migrated.credentialSecretRefs).toEqual(connection.credentialSecretRefs); + for (const invalidBinding of [ + { clientIssuer: "https://other.example" }, + { clientResource: "https://other.example/mcp" }, + { clientCompanyId: randomUUID() }, + { clientRedirectUri: "https://other.example/api/tools/oauth/callback" }, + ]) { + await db.update(toolConnections).set({ config: { ...connection.config, oauth: { + ...connection.config.oauth as Record, + clientIssuer: "https://mcp.asana.com", clientResource: "https://mcp.asana.com", + clientRedirectUri: "http://localhost:3200/api/tools/oauth/callback", ...invalidBinding, + } } }).where(eq(toolConnections.id, connection.id)); + await expect(service.startOAuth(company.id, connection.id, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + })).rejects.toMatchObject({ status: 422, details: { code: "oauth_manual_client_rebinding_required" } }); + } + const missingSecret = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", + oauthClient: { clientId: "another-client" }, + }); + await expect(service.startOAuth(company.id, missingSecret.connectionId, { + redirectUri: "http://localhost:3200/api/tools/oauth/callback", + actor: { actorType: "user", actorId: "board" }, + })).rejects.toMatchObject({ status: 422, message: expect.stringContaining("requires an OAuth client secret") }); + }); + it("stores a curated customer-owned OAuth client without exposing its secret", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", oauthClient: { clientId: "asana-customer-client", @@ -11653,6 +11733,7 @@ describeEmbeddedPostgres("tool access service", () => { const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { @@ -11668,11 +11749,101 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it.each([false, true])("finishes a second Asana account with a colliding profile name (installed: %s)", async (installed) => { + const company = await createCompany(db); + const userId = "asana-owner"; + await grantBoardUser(db, company.id, userId, [], "owner"); + const agent = await createAgent(db, company.id); + const actor = { actorType: "user" as const, actorId: userId }; + const credentials = { + v: 1 as const, accessToken: "asana-access", refreshToken: "asana-refresh", tokenType: "Bearer", + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + refreshTokenExpiresAt: null, scopes: ["default"], subject: userId, companyId: company.id, + instanceId: "test-instance", environment: "staging" as const, + provider: "asana" as const, profile: "asana.mcp" as const, + }; + const connector: PaperclipCloudConnector = { + getCapabilities: vi.fn(async () => ["asana.mcp"]), + startAuthorization: vi.fn(async ({ returnState }) => ({ + authorizationUrl: `https://app.asana.com/-/oauth_authorize?state=${encodeURIComponent(returnState)}`, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + })), + claim: vi.fn(async () => credentials), refresh: vi.fn(async () => credentials), + revoke: vi.fn(async () => undefined), + }; + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const custom = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", name: "Asana", grantKind: "user", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }, actor); + const first = await service.finishGalleryAppConnection(company.id, custom.connectionId, { + enabledCatalogEntryIds: [], askFirstCatalogEntryIds: [], access: { agentIds: [] }, + }, actor); + await service.updateConnection(custom.connectionId, { name: "Asana custom app" }); + const managed = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "managed", name: "Asana", grantKind: "user", + applicationId: custom.application.id, + }, actor); + expect(managed.connection.name).toBe("Asana"); + if (installed) await service.putConnectionInstalls(managed.connectionId, { + installs: [{ targetType: "agent", targetId: agent.id }], + }, actor); + const started = await service.startOAuth(company.id, managed.connectionId, { + redirectUri: "https://tenant.example/api/tools/oauth/cloud-connector/callback", actor, + }); + mockToolsList([{ name: "get_me", annotations: { readOnlyHint: true } }]); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "asana-claim", actor, + }); + expect(completed.connection).toMatchObject({ status: "active", enabled: true }); + expect(completed.catalog).toEqual([expect.objectContaining({ toolName: "get_me", status: "active" })]); + const repeat = await service.finishGalleryAppConnection(company.id, managed.connectionId, { + enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], + access: { agentIds: installed ? [agent.id] : [] }, + }, actor); + expect(repeat.profile.name).not.toBe(first.profile.name); + expect(repeat.profileBindings.map((binding) => binding.targetId)).toEqual(installed ? [agent.id] : []); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.companyId, company.id))).toHaveLength(2); + expect(await db.select().from(toolProfiles).where(eq(toolProfiles.id, first.profile.id))) + .toEqual([expect.objectContaining({ name: first.profile.name })]); + }); + + it("resumes personal Asana client credentials with database timestamp precision", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "asana-owner" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user", + oauthClient: { clientId: "asana-client", clientSecret: "asana-secret" }, + }, actor); + const before = await service.listConnectionGrants(connected.connectionId, company.id); + const grant = before.grants[0]!; + await db.update(connectionGrants).set({ updatedAt: sql`'2026-01-01 12:00:00.123456'::timestamp` }) + .where(eq(connectionGrants.id, grant.id)); + await service.connectGalleryApp(company.id, { + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", grantKind: "user", + resumeConnectionId: connected.connectionId, oauthClient: { clientId: "asana-client" }, + }, actor); + const after = await service.listConnectionGrants(connected.connectionId, company.id); + expect(after.grants).toHaveLength(1); + expect(after.grants[0]!.credentialSecretRefs).toEqual(grant.credentialSecretRefs); + expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(true); + expect((await service.listConnections(company.id, actor.actorId))[0].hasSavedOAuthClientSecret).toBe(true); + expect((await service.getConnection(connected.connectionId, company.id, "another-user")).hasSavedOAuthClientSecret).toBe(false); + expect((await service.getConnection(connected.connectionId, company.id)).hasSavedOAuthClientSecret).toBe(false); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, grant.id)); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false); + await db.update(connectionGrants).set({ status: "active", credentialSecretRefs: [] }).where(eq(connectionGrants.id, grant.id)); + expect((await service.getConnection(connected.connectionId, company.id, actor.actorId)).hasSavedOAuthClientSecret).toBe(false); + }); + it("does not retain a customer OAuth secret when the client id changes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", oauthClient: { clientId: "asana-first-client", @@ -11682,6 +11853,7 @@ describeEmbeddedPostgres("tool access service", () => { const resumed = await service.connectGalleryApp(company.id, { galleryKey: "asana", + connectionMethodKey: "mcp-own-oauth", name: "Asana own app", resumeConnectionId: connected.connectionId, oauthClient: { @@ -15478,18 +15650,8 @@ describeEmbeddedPostgres("tool access service", () => { ), ); - await db.insert(toolProfiles).values({ - companyId: company.id, - profileKey: `conflict-${randomUUID()}`, - name: "Conflicting app profile", - status: "active", - defaultAction: "deny", - }); - await db - .update(toolConnections) - .set({ name: "Conflicting app profile", updatedAt: new Date() }) - .where(eq(toolConnections.id, connect.connectionId)); - + // An absent actor trips the binding's foreign key after profile state is + // replaced. Profile-name collisions are now resolved during finalization. await expect( service.finishGalleryAppConnection( company.id, @@ -15499,7 +15661,7 @@ describeEmbeddedPostgres("tool access service", () => { askFirstCatalogEntryIds: [updateEntry.id], access: { agentIds: [agent.id] }, }, - { actorType: "user", actorId: "board" }, + { actorType: "agent", actorId: randomUUID() }, ), ).rejects.toThrow(); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index f4c51194977..ecf346118b7 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -393,7 +393,7 @@ export function toolAccessRoutes( : null; if ( parsed.host.toLowerCase() === normalizedRoutedHost - && (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) + && parsed.protocol === "https:" ) { return parsed.origin; } diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 576fc05f5b7..307c9262b51 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -262,6 +262,27 @@ describe("Paperclip Cloud connector", () => { }); }); + it.each([ + ["https://app.asana.com/-/oauth_authorize", true], + ["https://mcp.asana.com/authorize", false], + ["https://app.asana.com/other", false], + ])("validates Asana's exact v2 authorization endpoint %s", async (authorizationUrl, accepted) => { + const keys = config(); + const connector = createPaperclipCloudConnector({ config: keys.config, + request: vi.fn(async (_url, init) => { + const body = JSON.parse(String(init?.body)); + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ prv: "asana", prf: "asana.mcp", scp: ["default"] }); + return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=test", + authorizationUrl, expiresAt: "2099-01-01T00:00:00Z" }); + }) as typeof fetch, + }); + const result = connector.startAuthorization({ subject, companyId, profile: "asana.mcp", + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "state" }); + if (accepted) await expect(result).resolves.toMatchObject({ authorizationUrl }); + else await expect(result).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); + }); + it("accepts the fixed Google authorization endpoint for Google profiles", async () => { const keys = config(); const connector = createPaperclipCloudConnector({ diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 677d64fb1d6..fb52bed38ca 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,3 +1,4 @@ +import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -33,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId; -export type PaperclipCloudConnectorProvider = "google" | "github"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -754,6 +755,9 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (isAsanaConnectorProfileId(profile)) { + return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; + } if (isGitHubConnectorProfileId(profile)) { return { provider: "github", scopes: GITHUB_CONNECTOR_PROFILES[profile].scopes }; } @@ -764,6 +768,9 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (isAsanaConnectorProfileId(profile)) { + return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; + } if (isGitHubConnectorProfileId(profile)) { return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize"; } @@ -771,7 +778,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value); + return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 982a0e82bce..08d64725329 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,5 @@ import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; +import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js"; import { browserUseService } from "./browser-use.js"; @@ -2491,13 +2492,16 @@ export function isGoogleWorkspaceToolAllowed( } type ManagedConnectorProfileId = - GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId; + GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId; function managedConnectorProfile(value: string | undefined): { id: ManagedConnectorProfileId; - provider: "google" | "github"; + provider: "google" | "github" | "asana"; scopes: readonly string[]; } | null { + if (value && isAsanaConnectorProfileId(value)) { + return { id: value, provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; + } if (value && isGoogleWorkspaceConnectorProfileId(value)) { return { id: value, @@ -8402,6 +8406,27 @@ export function toolAccessService( return oauthClientConfig(provider); } + async function annotateSavedOAuthClientSecret(connections: ToolConnection[], viewerUserId?: string) { + const personal = connections.filter((connection) => connection.authKind === "oauth" && connection.credentialPolicy === "per_user"); + const grants = viewerUserId && personal.length > 0 + ? await db.select({ connectionId: connectionGrants.connectionId, refs: connectionGrants.credentialSecretRefs }) + .from(connectionGrants) + .where(and( + inArray(connectionGrants.companyId, [...new Set(personal.map((connection) => connection.companyId))]), + inArray(connectionGrants.connectionId, personal.map((connection) => connection.id)), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, viewerUserId), + eq(connectionGrants.status, "active"), + )) + : []; + const savedPersonal = new Set(grants.filter((grant) => grant.refs.some((ref) => ref.configPath === "oauth.client_secret")).map((grant) => grant.connectionId)); + for (const connection of connections) { + connection.hasSavedOAuthClientSecret = connection.credentialPolicy === "per_user" + ? savedPersonal.has(connection.id) + : connection.credentialSecretRefs.some((ref) => ref.configPath === "oauth.client_secret"); + } + } + async function oauthClientForConnection( connection: typeof toolConnections.$inferSelect, provider: string, @@ -9030,6 +9055,17 @@ export function toolAccessService( connection.ownership !== "dcr" && typeof storedOAuth.clientId === "string" && storedOAuth.clientId.trim().length > 0; + // A reviewed protected-resource document is authoritative for this method. + // Some hosts still serve metadata for a retired MCP server at the root. + // Resolve it before cached endpoints, including on existing draft reconnects. + if (!smokeLabEndpoints && galleryMethod?.defaults?.discoveryUrl) { + const endpoints = await endpointsFromMetadataUrl( + connection, galleryMethod.defaults.discoveryUrl, [], originOf(redirectUri), + ); + if (!endpoints) throw unprocessable("OAuth provider metadata could not be loaded"); + assertNotSmokeLabOAuthEndpoints(connection, endpoints); + return endpoints; + } const hasCompleteGalleryEndpointHints = Boolean( galleryMethod?.defaults?.authorizationEndpoint && galleryMethod.defaults.tokenEndpoint && @@ -9637,13 +9673,34 @@ export function toolAccessService( * reusing them across a moved binding would let a re-pointed endpoint borrow * another server's registration. */ + function reviewedOAuthClientBinding(connection: typeof toolConnections.$inferSelect, endpoints: OAuthProviderEndpoints) { + const oauth = oauthConfig(connection); + // Only repair the known Asana v1 discovery mistake. Retain company and + // callback checks, and never carry a secret to an arbitrary new issuer. + if (connection.config.sourceTemplateKey === "asana" + && connection.config.connectionMethodKey === "mcp-own-oauth" + && connection.config.url === "https://mcp.asana.com/v2/mcp" + && oauth.clientRegistrationSource === "manual" + && connection.ownership !== "dcr" + && endpoints.issuer === "https://app.asana.com" + && endpoints.authorizationUrl === "https://app.asana.com/-/oauth_authorize" + && endpoints.tokenUrl === "https://app.asana.com/-/oauth_token" + && endpoints.resource === "https://mcp.asana.com/v2/mcp" + && [undefined, null, "https://mcp.asana.com", "https://app.asana.com"].includes(oauth.clientIssuer as string | null | undefined) + && [undefined, null, "https://mcp.asana.com", "https://mcp.asana.com/v2/mcp"].includes(oauth.clientResource as string | null | undefined) + && (oauth.clientIssuer === "https://mcp.asana.com" || oauth.clientResource === "https://mcp.asana.com")) { + return { ...oauth, clientIssuer: endpoints.issuer, clientResource: endpoints.resource }; + } + return oauth; + } + function oauthClientBindingMatches( connection: typeof toolConnections.$inferSelect, endpoints: OAuthProviderEndpoints, redirectUri: string, clientIdMetadataDocumentUrl: string | null, ): boolean { - const oauth = oauthConfig(connection); + const oauth = reviewedOAuthClientBinding(connection, endpoints); if (typeof oauth.clientId !== "string" || !oauth.clientId.trim()) return false; const source = @@ -9708,7 +9765,8 @@ export function toolAccessService( endpoints: OAuthProviderEndpoints, redirectUri: string, ): Promise { - const oauth = oauthConfig(connection); + const originalOAuth = oauthConfig(connection); + const oauth = reviewedOAuthClientBinding(connection, endpoints); const nextBinding = { clientRedirectUri: redirectUri, clientIssuer: @@ -9725,7 +9783,7 @@ export function toolAccessService( : connection.companyId, }; const unchanged = Object.entries(nextBinding).every( - ([key, value]) => oauth[key] === value, + ([key, value]) => originalOAuth[key] === value, ); if (unchanged) return connection; const nextConfig = { @@ -13339,6 +13397,19 @@ export function toolAccessService( ), ) .limit(1); + // A renamed connection can leave its old profile name in use. Resolve + // names against profiles too, including when finalizing an installed draft. + const otherProfiles = await tx + .select({ name: toolProfiles.name }) + .from(toolProfiles) + .where(and( + eq(toolProfiles.companyId, companyId), + ne(toolProfiles.profileKey, profileKey), + )); + const profileName = nextAvailableConnectionName( + connection.name, + otherProfiles.map((profile) => profile.name), + ); let profileId: string; if (existingProfile) { if (input.preserveExistingAccess) { @@ -13418,7 +13489,7 @@ export function toolAccessService( const [updated] = await tx .update(toolProfiles) .set({ - name: connection.name, + name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", @@ -13437,7 +13508,7 @@ export function toolAccessService( .values({ companyId, profileKey, - name: connection.name, + name: profileName, description: `Access profile for ${connection.name}.`, status: "active", defaultAction: "deny", @@ -14137,7 +14208,9 @@ export function toolAccessService( issuer: managedProfile.provider === "github" ? "https://github.com" - : "https://accounts.google.com", + : managedProfile.provider === "asana" + ? "https://app.asana.com" + : "https://accounts.google.com", resource: galleryMethod.defaults?.serverUrl ?? null, registrationSource: null, }; @@ -14161,6 +14234,9 @@ export function toolAccessService( }); connection = resolvedClient.connection; const client = resolvedClient.client; + if (galleryMethod?.oauthClientSecretRequired && !client.clientSecret) { + throw unprocessable("This app requires an OAuth client secret. Add it in the app setup before signing in."); + } if (!client.clientId) throw unprocessable( `OAuth client id is not configured for ${endpoints.provider}`, @@ -14765,9 +14841,9 @@ export function toolAccessService( redemptionId: input.state, }); const refreshToken = credentials.refreshToken; - if (profile.provider === "google" && !refreshToken) { + if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { throw unprocessable( - `Google did not return offline access. Reconnect ${providerName} and grant the requested scopes.`, + `${providerName} did not return offline access. Reconnect and grant the requested permissions.`, { code: "oauth_refresh_missing", }, @@ -17180,6 +17256,7 @@ export function toolAccessService( } await annotateAiGrantHealth(connections); await annotateGitHubAuthorization(connections, viewerUserId); + await annotateSavedOAuthClientSecret(connections, viewerUserId); return connections; }, @@ -17284,6 +17361,7 @@ export function toolAccessService( ); await annotateAiGrantHealth([connection]); await annotateGitHubAuthorization([connection], viewerUserId); + await annotateSavedOAuthClientSecret([connection], viewerUserId); return connection; }, diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 2562372c366..7a01867fc7a 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -979,7 +979,12 @@ function StandardConnectionSetupFlow({ const fullRequestedDefinition = requestedAppKey ? getConnectableAppDefinition(requestedAppKey) : null; - const requestedDefinitionUsesManagedConnector = Boolean( + const customOAuthMethod = fullRequestedDefinition?.methods.find((candidate) => + connectionMethodAcceptsCustomerOAuthClient(candidate) + && !connectionMethodSupportsAutomaticOAuth(candidate), + ); + const selectedCustomOAuth = Boolean(customOAuthMethod && connectionMethodKey === customOAuthMethod.key); + const requestedDefinitionUsesManagedConnector = !selectedCustomOAuth && Boolean( fullRequestedDefinition?.methods.some((candidate) => candidate.oauthStrategy === "paperclip_cloud_connector" || candidate.oauthStrategy === "paperclip_id_connector" @@ -1613,14 +1618,16 @@ function StandardConnectionSetupFlow({ }, [resumeConnectionId]); useEffect(() => { + const savedConnection = resumeConnection ?? reconnectConnection; + const savedOAuth = savedConnection?.config?.oauth as Record | undefined; if ( - !resumeConnection + !savedConnection || !entry - || resumeConnection.status !== "draft" - || hydratedResumeConnectionIdRef.current === resumeConnection.id + || (savedConnection.status !== "draft" && savedOAuth?.clientRegistrationSource !== "manual") + || hydratedResumeConnectionIdRef.current === savedConnection.id ) return; - const storedConfig = resumeConnection.config && typeof resumeConnection.config === "object" - ? resumeConnection.config + const storedConfig = savedConnection.config && typeof savedConnection.config === "object" + ? savedConnection.config : {}; const storedSource = typeof storedConfig.sourceTemplateKey === "string" ? storedConfig.sourceTemplateKey @@ -1635,7 +1642,7 @@ function StandardConnectionSetupFlow({ (candidate) => candidate.key === storedMethodKey, ) ?? null : null; - setGalleryName(resumeConnection.name || entry.name); + setGalleryName(savedConnection.name || entry.name); if (resumedMethod) { setConnectionMethodKey(resumedMethod.key); const storedMethodConfig = storedConfig.methodConfig && typeof storedConfig.methodConfig === "object" @@ -1676,9 +1683,9 @@ function StandardConnectionSetupFlow({ setOAuthError(null); } setStep("key"); - hydratedResumeConnectionIdRef.current = resumeConnection.id; - setHydratedResumeConnectionId(resumeConnection.id); - }, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection]); + hydratedResumeConnectionIdRef.current = savedConnection.id; + setHydratedResumeConnectionId(savedConnection.id); + }, [credentialSource, entry, oauthCallbackCode, oauthCallbackOutcome, resumeConnection, reconnectConnection]); /** * Commit the connection: action defaults, agent reach, and installs. @@ -1971,7 +1978,7 @@ function StandardConnectionSetupFlow({ ) ) : null; // A provider can advertise registration and still refuse this deployment's - // callback (Asana refuses hosted ones). When the method also accepts an + // callback. When the method also accepts an // operator's own OAuth client, that client is the recovery path, so it sits // in the same Advanced panel and opens itself once sign-in has failed. const automaticCustomerClientMethod = automaticOAuthEntry @@ -2295,6 +2302,11 @@ function StandardConnectionSetupFlow({

This instance is connected to Paperclip, but {entry.name} sign-in is not currently available. Try again shortly or contact your instance administrator.

+ {customOAuthMethod ? ( + + ) : null}
+ {customOAuthMethod ? ( + + ) : null} + {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} @@ -2368,6 +2386,11 @@ function StandardConnectionSetupFlow({ oauthClientId={curatedOAuthClientId} onOAuthClientIdChange={setCuratedOAuthClientId} oauthClientSecret={curatedOAuthClientSecret} + canReuseOAuthClientSecret={Boolean( + identityConnection?.config?.oauth + && (identityConnection.config.oauth as Record).clientId === curatedOAuthClientId.trim() + && identityConnection.hasSavedOAuthClientSecret + )} onOAuthClientSecretChange={setCuratedOAuthClientSecret} credentialSource={credentialSource} vercelConnector={vercelConnector} @@ -3401,6 +3424,7 @@ function KeyStep({ oauthClientId, onOAuthClientIdChange, oauthClientSecret, + canReuseOAuthClientSecret, onOAuthClientSecretChange, credentialSource, vercelConnector, @@ -3425,6 +3449,7 @@ function KeyStep({ oauthClientId: string; onOAuthClientIdChange: (next: string) => void; oauthClientSecret: string; + canReuseOAuthClientSecret: boolean; onOAuthClientSecretChange: (next: string) => void; credentialSource: ToolConnectionCredentialSource; vercelConnector: string; @@ -3515,7 +3540,10 @@ function KeyStep({ const acceptsCustomerOAuthClient = connectionMethodAcceptsCustomerOAuthClient(method); const customerOAuthClientRequired = acceptsCustomerOAuthClient && !connectionMethodSupportsAutomaticOAuth(method); - const oauthClientFilled = usingVercel || !customerOAuthClientRequired || oauthClientId.trim().length > 0; + const oauthClientFilled = usingVercel || !customerOAuthClientRequired || ( + oauthClientId.trim().length > 0 + && (!method?.oauthClientSecretRequired || canReuseOAuthClientSecret || oauthClientSecret.trim().length > 0) + ); const vercelConnectorFilled = !usingVercel || vercelConnector.trim().length > 0; const oauthCallbackUrl = method?.auth === "oauth" && acceptsCustomerOAuthClient ? oauthCallbackUrlForBrowser() @@ -3565,27 +3593,26 @@ function KeyStep({ {!capabilityKey &&

Choose an access level to continue.

} ) : null; - const managedGoogleMethod = capabilityMethods.find((candidate) => - candidate.oauthStrategy === "paperclip_cloud_connector" - && isGoogleWorkspaceConnectorProfileId(candidate.connectorProfile ?? ""), + const managedOAuthMethod = capabilityMethods.find((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector", ); - const customerGoogleMethod = managedGoogleMethod && capabilityMethods.find((candidate) => + const customerOAuthMethod = managedOAuthMethod && capabilityMethods.find((candidate) => connectionMethodAcceptsCustomerOAuthClient(candidate) && !connectionMethodSupportsAutomaticOAuth(candidate), ); - const usingCustomGoogleOAuth = method?.key === customerGoogleMethod?.key; - const googleOAuthFieldsId = useId(); - const authenticationSelection = managedGoogleMethod && customerGoogleMethod && capabilityMethods.length === 2 ? ( + const usingCustomOAuth = method?.key === customerOAuthMethod?.key; + const customOAuthFieldsId = useId(); + const authenticationSelection = managedOAuthMethod && customerOAuthMethod && capabilityMethods.length === 2 ? ( ) : capabilityMethods.length > 1 ? ( // PAP-659 C1: the ranked default is already selected. This stays as the @@ -3614,7 +3641,7 @@ function KeyStep({ || hasAlternateMethods; // Keep the disclosure open when what is inside it is load-bearing right now: // a non-default method in use, or a selection the connector still needs. - const forceAdvancedOpen = usingCustomGoogleOAuth || !hasMethodSelection; + const forceAdvancedOpen = usingCustomOAuth || !hasMethodSelection; const advancedSettings = hasAdvancedSettings ? (
{capabilitySelection} @@ -3705,7 +3732,8 @@ function KeyStep({ ); } - const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl; + const managedAsana = method?.connectorProfile === "asana.mcp"; + const requirementsUrl = managedAsana ? null : method?.consoleLinks?.docs ?? entry.docsUrl; return (
@@ -3724,6 +3752,9 @@ function KeyStep({ ) : null}
+ {managedAsana ? ( +

Sign in with Asana to choose your workspace and connect it to Paperclip.

+ ) : null} {method?.capabilityProfile && (

{method.capabilityProfile.label}: {method.capabilityProfile.description} @@ -3778,7 +3809,7 @@ function KeyStep({ {!usingVercel && method?.auth === "oauth" && customerOAuthClientRequired ? ( -

+
- {required ? `${entry.name} needs its own OAuth app` : "Use your own OAuth app"} + {required ? method.oauthClientSecretRequired ? "Your OAuth app" : `${entry.name} needs its own OAuth app` : "Use your own OAuth app"}
{/* When these fields are required it is a provider limitation, not a step @@ -3900,7 +3931,7 @@ function OAuthClientFields({ exception it is rather than as this connector's normal path. */}

- {required + {method.oauthClientSecretRequired ? method.guidanceMd : required ? `${entry.name} does not let Paperclip register itself automatically, so this connector needs an OAuth app you create. Add the callback URL below in ${entry.name}, then paste the client details back here.` : `Register Paperclip's callback URI in ${entry.name}, then enter the customer-owned client details.`}

@@ -3956,7 +3987,7 @@ function OAuthClientFields({ value={clientSecret} onChange={(event) => onClientSecretChange(event.target.value)} autoComplete="off" - placeholder="Optional for public clients" + placeholder={method.oauthClientSecretRequired ? "Required" : "Optional for public clients"} className="mt-2 h-11 font-mono" />
diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index aad46401c4d..82309bf53ee 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -53,6 +53,10 @@ const GITHUB_MANAGED = { }; const NOTION = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "notion")!; const ASANA = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "asana")!; +const ASANA_MANAGED = { + ...ASANA, + ownershipAvailability: { ...ASANA.ownershipAvailability, platform_shared: true }, +}; const BOX = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "box")!; const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog")!; const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; @@ -981,6 +985,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(gmail?.textContent).not.toContain("Coming soon"); }); + it("defaults Asana to managed sign-in and allows a custom app before enrollment", async () => { + mockSearch.value = "source=asana"; + listGalleryMock.mockResolvedValue({ apps: [{ ...ASANA, methods: ASANA.methods.filter((method) => !method.oauthStrategy), ownershipAvailability: { + ...ASANA.ownershipAvailability, platform_shared: false, + } }] }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ configured: false, status: "not_configured", origins: [] }); + await render(); + await passAccessStep(); + expect(container.textContent).toContain("Connect with Paperclip"); + await act(async () => { buttonByText("Use your own Asana OAuth app")!.click(); }); + await flushReact(); + expect(container.textContent).toContain("Your OAuth app"); + expect(container.textContent).toContain("API apps do not work with Asana MCP"); + expect(container.textContent).not.toContain("You must connect this instance"); + await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "asana-client"); }); + await flushReact(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(true); + expect(startCloudConnectorEnrollmentMock).not.toHaveBeenCalled(); + }); + + it.each([true, false])("requires the acting user's saved Asana client secret for reuse (%s)", async (hasSavedOAuthClientSecret) => { + mockSearch.value = "source=asana&resume=conn-asana"; + listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + listApplicationsMock.mockResolvedValue({ applications: [{ id: "app-asana", status: "draft", metadata: { sourceTemplateKey: "asana" } }] }); + listConnectionsMock.mockResolvedValue({ connections: [{ + id: "conn-asana", applicationId: "app-asana", name: "Asana", authKind: "oauth", + credentialPolicy: "per_user", status: "draft", credentialSecretRefs: [], hasSavedOAuthClientSecret, + config: { sourceTemplateKey: "asana", connectionMethodKey: "mcp-own-oauth", oauth: { clientId: "saved-asana-client", clientRegistrationSource: "manual" } }, + transportConfig: {}, + }] }); + await render(); + await flushReact(); + expect(container.querySelector("#curated-oauth-client-id")?.value).toBe("saved-asana-client"); + expect(buttonByText("Continue to sign in")?.disabled).toBe(!hasSavedOAuthClientSecret); + await act(async () => { setInputValue(container.querySelector("#curated-oauth-client-id")!, "different-client"); }); + await flushReact(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(true); + }); + it("collects customer-owned OAuth client details for a curated manual OAuth app", async () => { listGalleryMock.mockResolvedValue({ apps: [BOX] }); mockParams.appKey = "box"; @@ -1023,28 +1066,27 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { })); }); - it("gives Asana the one-click path now that its server advertises registration", async () => { - listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + it("connects Asana through the shared app without client credentials", async () => { + listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); mockParams.appKey = "asana"; await render(); await flushReact(); - // No console detour: the connect screen is the handoff, not a form. expect(container.textContent).not.toContain("needs its own OAuth app"); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); - const primary = Array.from(container.querySelectorAll("button")).find((b) => - b.textContent?.trim().startsWith("Continue to"), - ); + const primary = buttonByText("Continue to sign in"); expect(primary).toBeTruthy(); expect(primary?.disabled).toBe(false); + await act(async () => { primary!.click(); }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "asana", connectionMethodKey: "managed", grantKind: "user", + })); }); - it("offers Asana's own-OAuth-app fields as the recovery when registration is refused", async () => { - // Asana advertises registration but refuses hosted callbacks, so a failed - // sign-in must still leave the customer-owned client path within reach. - listGalleryMock.mockResolvedValue({ apps: [ASANA] }); + it("allows a custom Asana app from the shared sign-in setup", async () => { + listGalleryMock.mockResolvedValue({ apps: [ASANA_MANAGED] }); mockParams.appKey = "asana"; - connectAppMock.mockRejectedValueOnce(new Error("Asana refused the redirect URI.")); connectAppMock.mockResolvedValueOnce({ connectionId: "conn-asana", application: { id: "app-asana", name: "Asana" }, @@ -1058,25 +1100,28 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); expect(container.querySelector("#curated-oauth-client-id")).toBeNull(); - await act(async () => { - buttonByText("Continue to Asana")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); + await openAccessAdvanced(); await flushReact(); + const customApp = buttonByText("Use your own Asana OAuth app"); + expect(customApp).toBeTruthy(); + await act(async () => { customApp!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); const clientId = container.querySelector("#curated-oauth-client-id"); + const clientSecret = container.querySelector("#curated-oauth-client-secret"); expect(clientId).toBeTruthy(); - await act(async () => setInputValue(clientId!, "asana-own-client")); - await flushReact(); + expect(clientSecret).toBeTruthy(); await act(async () => { - buttonByText("Try again")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + setInputValue(clientId!, "asana-own-client"); + setInputValue(clientSecret!, "asana-own-secret"); }); await flushReact(); + await act(async () => { buttonByText("Continue to sign in")!.click(); }); await flushReact(); expect(connectAppMock).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ - galleryKey: "asana", - oauthClient: expect.objectContaining({ clientId: "asana-own-client" }), + galleryKey: "asana", connectionMethodKey: "mcp-own-oauth", + oauthClient: { clientId: "asana-own-client", clientSecret: "asana-own-secret" }, })); }); diff --git a/ui/src/pages/apps/app-connect-policy.test.ts b/ui/src/pages/apps/app-connect-policy.test.ts index e4a5fa1b7e6..a11c224f411 100644 --- a/ui/src/pages/apps/app-connect-policy.test.ts +++ b/ui/src/pages/apps/app-connect-policy.test.ts @@ -15,14 +15,11 @@ describe("app connect policy", () => { expect(MCP_DIRECT_OAUTH_CONNECT_SLUGS).toEqual(expect.arrayContaining(["jira", "notion", "sentry"])); expect(isMcpDirectOAuthConnectSlug("notion")).toBe(true); expect(isMcpDirectOAuthConnectSlug("jira")).toBe(true); - // PAP-659 step 4: Asana's own OAuth metadata advertises registration, and - // live discovery now outranks its pinned customer-only ownership mode, so - // it reaches the provider directly instead of the client-ID form. - expect(isMcpDirectOAuthConnectSlug("asana")).toBe(true); - // Still false, and for two different reasons worth keeping apart: GitHub's - // one-click path is Paperclip-managed rather than direct, and Slack really - // does require a customer-registered OAuth client. + // Asana and GitHub need an available Paperclip-managed profile for + // one-click sign-in. The static catalog defaults to their custom-app path. + expect(isMcpDirectOAuthConnectSlug("asana")).toBe(false); expect(isMcpDirectOAuthConnectSlug("github")).toBe(false); + // Slack requires a customer-registered OAuth client. expect(isMcpDirectOAuthConnectSlug("slack")).toBe(false); expect(isMcpDirectOAuthConnectSlug(null)).toBe(false); });