From ba9e6e4bc5b327e19b95bd2d6ea8f09609170cf1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:21:43 -0500 Subject: [PATCH] refactor(tools): isolate authorization lifecycle (#17162) 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. > - Browser authorization binds a consenting identity to a company connection. > - Direct OAuth, managed-cloud claims and Vercel authorization have different state and persistence rules. > - Keeping all of them in the general tool service obscures those rules and their cleanup owners. > - This pull request separates state, initiation, provider completion and explicit identity promotion. > - The benefit is reviewable authorization flows with their existing authority checks and ordering preserved. ## Linked Issues or Issue Description Depends on #17161. Earlier stack: #17144 → #17146 → #17147 → #17155 → #17157 → #17159 → #17161 → this PR. **What existing behavior does this improve?** Maintenance of browser authorization and credential-sharing lifecycle. **Current behavior** The 12,019-line service combines provider callbacks, actor-bound states, authorization URLs and personal-to-company credential promotion. **Proposed behavior** Separate owners handle authorization state, start, direct/cloud/Vercel callbacks, catalog completion and explicit access finalization. Shared setup policy and identity checks have typed APIs used by the coordinator and authorization owners. **Reason and benefit** The entry point falls to 8,997 lines, a reduction of 3,022. Provider-specific state consumption and durable-write rules are visible in their own modules. ## What Changed - Extract authorization flows and their constraints; the largest new module is 700 lines. - Preserve direct/Vercel terminal state consumption and managed-cloud retryability until claim persistence commits. - Keep membership and grant locks, secret transaction clients, interaction resolution and catalog ordering unchanged. - Keep partial secret acquisition and cleanup together in access finalization. - Give shared organization-grant persistence, personal identity checks, setup policy and response projections named owners. ## Verification - All 470 public service and real local HTTP/PostgreSQL OAuth cases pass before and after extraction. - Coverage includes same-actor state binding, replay/denial, scope selection, membership revocation, provider failures, refresh concurrency and persisted usable credentials. - Server typecheck, module-boundary gate and whitespace checks pass. - All 182 pre-existing function/method bodies match by normalized syntax comparison. - The completed stack passes 525 targeted cases, full repository typecheck and build. The earlier interrupted local full suite remains unverified; all current-head CI gates pass; Greptile is 5/5 with no open review threads. - Original CI failure retained: [server shard 1/12](https://github.com/paperclipai/paperclip/actions/runs/39710536789/job/119600621627), `workspace-runtime.test.ts` case “replaces a reused Paperclip dev runtime whose 2xx health payload is unhealthy”. The fixture failed its initial health readiness with `ECONNREFUSED` after 97 probes / 10 seconds. The fixture and runtime implementation are unchanged and do not import this service. The isolated case passes without changing its timeout or assertions. One retry of only that shard passed at the same commit. All CI gates are now green; the original failure remains recorded here. ## Risks - Provider flows intentionally consume authorization state at different points. This extraction preserves each flow rather than sharing its completion algorithm. - Promotion must remove partially created secrets on failure and retire source secrets only after persistence. Both phases remain in the same owner with unchanged ordering. - Local HTTP fixtures prove assembled application behavior, not live third-party qualification. - No schema, public API or authorization policy change is intended. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The exact serving model ID and context-window size 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 --- .../2026-10-21-tool-access-boundaries.md | 6 + server/src/services/tool-access.ts | 3104 +---------------- server/src/services/tool-access/actor.ts | 12 + .../tool-access/applications/projection.ts | 23 + .../tool-access/catalog/action-summary.ts | 38 + .../services/tool-access/catalog/contracts.ts | 13 + .../connections/organization-grant.ts | 109 + .../connections/personal-identity.ts | 77 + .../tool-access/connections/setup-policy.ts | 45 + .../tool-access/oauth/access-finalization.ts | 431 +++ .../oauth/authorization-constraints.ts | 79 + .../tool-access/oauth/authorization-start.ts | 655 ++++ .../tool-access/oauth/catalog-completion.ts | 153 + .../tool-access/oauth/cloud-callback.ts | 519 +++ .../tool-access/oauth/direct-callback.ts | 700 ++++ .../src/services/tool-access/oauth/state.ts | 188 + .../tool-access/oauth/vercel-callback.ts | 257 ++ 17 files changed, 3346 insertions(+), 3063 deletions(-) create mode 100644 server/src/services/tool-access/actor.ts create mode 100644 server/src/services/tool-access/applications/projection.ts create mode 100644 server/src/services/tool-access/catalog/action-summary.ts create mode 100644 server/src/services/tool-access/catalog/contracts.ts create mode 100644 server/src/services/tool-access/connections/organization-grant.ts create mode 100644 server/src/services/tool-access/connections/personal-identity.ts create mode 100644 server/src/services/tool-access/connections/setup-policy.ts create mode 100644 server/src/services/tool-access/oauth/access-finalization.ts create mode 100644 server/src/services/tool-access/oauth/authorization-constraints.ts create mode 100644 server/src/services/tool-access/oauth/authorization-start.ts create mode 100644 server/src/services/tool-access/oauth/catalog-completion.ts create mode 100644 server/src/services/tool-access/oauth/cloud-callback.ts create mode 100644 server/src/services/tool-access/oauth/direct-callback.ts create mode 100644 server/src/services/tool-access/oauth/state.ts create mode 100644 server/src/services/tool-access/oauth/vercel-callback.ts diff --git a/doc/plans/2026-10-21-tool-access-boundaries.md b/doc/plans/2026-10-21-tool-access-boundaries.md index dd555774538..f4506339ca9 100644 --- a/doc/plans/2026-10-21-tool-access-boundaries.md +++ b/doc/plans/2026-10-21-tool-access-boundaries.md @@ -196,3 +196,9 @@ boundaries pass. No new supervisor, background loop or runtime cache is created. - `tool-access.ts`: 14,877 → 12,019 lines. Registration single-flight remains process-wide; refresh maps and GitHub sweep state remain per service. The live cloud connector getter and existing secret client are passed through. - The real HTTP/PostgreSQL OAuth fixture now also proves concurrent refresh across two service instances, one token exchange, durable rotated credentials and authenticated use of the new token. Its two cases passed before the move. - After extraction, 470 public service/integration cases, server typecheck and module boundary checks pass. All 233 pre-existing function/method bodies match by normalized syntax comparison. + +### Authorization lifecycle + +- Separate state validation/consumption, authorization initiation, direct OAuth callback, managed-cloud callback, Vercel callback, catalog completion and explicit identity promotion. Shared setup policy, personal identity checks and organization-grant persistence have explicit owners. +- `tool-access.ts`: 12,019 → 8,997 lines. The largest new module is 700 lines. Callback-specific state consumption order, membership locks, transaction-scoped secret writes and promotion cleanup stay intact. +- Before/after: all 470 public service and real HTTP/database integration cases pass. Server typecheck, module-boundary and whitespace checks pass; all 182 previous function/method bodies match by normalized syntax comparison. diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 0bb68e89128..5fc47d6a840 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,11 +1,24 @@ +import { actorBinding } from "./tool-access/actor.js"; +import { toApplication } from "./tool-access/applications/projection.js"; +import { groupedActions } from "./tool-access/catalog/action-summary.js"; +import { createConnectionSetupPolicy, assertSupportedConnection } from "./tool-access/connections/setup-policy.js"; +import { createOrganizationGrantStore } from "./tool-access/connections/organization-grant.js"; +import { createPersonalConnectionIdentity } from "./tool-access/connections/personal-identity.js"; +import { createOAuthStateStore } from "./tool-access/oauth/state.js"; +import { createOAuthCatalogCompletion } from "./tool-access/oauth/catalog-completion.js"; +import { createOAuthAuthorizationStart } from "./tool-access/oauth/authorization-start.js"; +import { createDirectOAuthCallback } from "./tool-access/oauth/direct-callback.js"; +import { createCloudOAuthCallback } from "./tool-access/oauth/cloud-callback.js"; +import { createVercelOAuthCallback } from "./tool-access/oauth/vercel-callback.js"; +import { createOAuthAccessFinalization } from "./tool-access/oauth/access-finalization.js"; import { createVercelCredentialService, vercelConnectHttpError, vercelCredentialFor, vercelConnectResourcesFor, redactedVercelGrant } from "./tool-access/vercel-credentials.js"; import { createConnectionCredentialAccess } from "./tool-access/credential-access.js"; import { singleFlight } from "./tool-access/single-flight.js"; import { connectionSetupUrl, connectionReconnectUrl } from "./tool-access/identifiers.js"; import { createOAuthCredentialStore, actorForSecret } from "./tool-access/oauth/credential-store.js"; -import { credentialScope, managedConnectorProfile } from "./tool-access/oauth/client-settings.js"; +import { managedConnectorProfile } from "./tool-access/oauth/client-settings.js"; import { createOAuthClientRegistration } from "./tool-access/oauth/client-registration.js"; -import { createOAuthRefreshLeases, withoutOAuthRefreshLease } from "./tool-access/oauth/refresh-leases.js"; +import { createOAuthRefreshLeases } from "./tool-access/oauth/refresh-leases.js"; import { createLegacyOAuthRefresh } from "./tool-access/oauth/connection-refresh.js"; import { createOAuthGrantRefresh } from "./tool-access/oauth/grant-refresh.js"; import { createGitHubGrantContinuity } from "./tool-access/github-grant-continuity.js"; @@ -25,11 +38,11 @@ import type { ActorInfo, ToolAccessMutationDb } from "./tool-access/types.js"; import { normalizeKey, connectionUid } from "./tool-access/identifiers.js"; import { createOAuthEndpointPolicy } from "./tool-access/oauth/endpoint-policy.js"; import { createOAuthDiscovery } from "./tool-access/oauth/discovery.js"; -import { createOAuthClientProtocol, storedOAuthTokenEndpointAuthMethod } from "./tool-access/oauth/client.js"; -import { normalizeOAuthProviderError, oauthProviderErrorMessage } from "./tool-access/oauth/provider-errors.js"; -import { oauthConfig, normalizeOauthScopes, resolveGrantedOauthScopes, sameOAuthIssuer, originOf } from "./tool-access/oauth/metadata.js"; +import { createOAuthClientProtocol } from "./tool-access/oauth/client.js"; -import type { OAuthProviderEndpoints, OAuthClientRegistrationSource } from "./tool-access/oauth/types.js"; +import { oauthConfig } from "./tool-access/oauth/metadata.js"; + +import type { OAuthClientRegistrationSource } from "./tool-access/oauth/types.js"; export { OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, resolveOAuthClientIdMetadataDocumentUrl, oauthClientIdMetadataDocument } from "./tool-access/oauth/client-metadata.js"; import { remoteEndpoint, @@ -48,9 +61,9 @@ export { projectedConnectionToolArguments, projectedConnectionToolInputSchema, } from "./tool-access/connection-methods.js"; -import { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; +import { loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; export { loadGitHubGrantMetadata, loadGitHubTokenRepositories } from "./tool-access/github-access-discovery.js"; -import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared"; + import { stableHash, descriptorHash } from "./tool-catalog-hash.js"; import { DAYTONA_TOOLS, daytonaClient, daytonaRisk, isDaytonaConnection, isDaytonaToolName } from "./daytona.js"; @@ -66,7 +79,7 @@ import { COMPOSIO_APP_TOOLKITS, findComposioCatalogApp } from "@paperclipai/shar import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput, ComposioAppSetupResult, ComposioAppSnapshot, ComposioAppsResponse } from "@paperclipai/shared"; import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; -import { googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, isGoogleWorkspaceScopeGrant, type GoogleWorkspaceService } from "@paperclipai/shared"; +import { GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, type GoogleWorkspaceService } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; @@ -75,7 +88,7 @@ import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.j import { browserUseService } from "./browser-use.js"; import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl } from "./cognee-connection.js"; import { isMemoryConnectorId, isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@paperclipai/shared"; -import { instanceSettingsService } from "./instance-settings.js"; + import { githubBotRequest } from "./chat-github-client.js"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; import { @@ -86,29 +99,13 @@ import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./proje import { measureRunPhase } from "./execution-preparation-timing.js"; -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { - and, - asc, - desc, - eq, - gte, - inArray, - isNotNull, - isNull, - lt, - lte, - max, - ne, - notInArray, - or, - sql, -} from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, lte, max, ne, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, authUsers, companyMemberships, companySecretBindings, companySecrets, principalPermissionGrants, userSecretDefinitions, userSecretDeclarations, issueThreadInteractions, plugins, toolAccessAuditEvents, toolApplications, toolActionRequests, toolCatalogEntries, toolConnectionInstalls, toolConnectionAppSnapshots, toolConnectionAppSyncs, toolConnections, toolOauthStates, toolStdioCommandTemplates, toolCallEvents, toolInvocations, toolPolicies, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db"; -import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, FinalizeOAuthAccess, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionHealthStatus, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolCredentialSecretRef, ToolOAuthStartResult, ToolAppsAttentionResponse, ToolAppMetadataPreflightResult, ToolAppConnectionActionSummary, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolPolicy, ToolRiskLevel, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference } from "@paperclipai/shared"; -import { CLASS3_STATIC_LEASE_ALLOWLIST, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, connectionIntentPayloadSchema, getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, isGoogleWorkspaceConnectorProfileId, isToolConnectionAttentionHealth, recommendedDefaultsForApp, type GoogleWorkspaceConnectorProfileId } from "@paperclipai/shared"; +import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, connectionTokenIssuances, authUsers, companyMemberships, companySecretBindings, companySecrets, principalPermissionGrants, userSecretDefinitions, userSecretDeclarations, plugins, toolAccessAuditEvents, toolApplications, toolActionRequests, toolCatalogEntries, toolConnectionInstalls, toolConnectionAppSnapshots, toolConnectionAppSyncs, toolConnections, toolOauthStates, toolStdioCommandTemplates, toolCallEvents, toolInvocations, toolPolicies, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db"; +import type { AppDefinition, ConnectionGrantKind, CreateToolApplication, CreateToolConnection, ConnectToolApp, ConnectToolAppResult, CreateToolStdioCommandTemplate, FinishToolApp, FinishToolAppResult, CreateToolProfileBindingForProfile, CreateToolProfileEntryForProfile, DeploymentExposure, DeploymentMode, ImportMcpJson, McpConnectionCredentialRef, McpJsonImportPreview, ToolApplication, ToolCatalogEntry, ToolCatalogRefreshResult, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, ToolConnectionRemovalResult, ToolConnectionRemovalSummary, ToolConnectionHealthCheckResult, ToolConnectionHealthStatus, ToolConnectionAuthKind, ToolConnectionCredentialPolicy, ToolConnectionCredentialSource, ToolConnectionTransport, ToolAppsAttentionResponse, ToolAppMetadataPreflightResult, ToolExampleInstallResult, ToolExampleSmokeCheck, ToolExampleSmokeResult, ToolExampleSummary, ToolProfileBinding, ToolProfileEntry, ToolPolicyDecision, ToolPolicy, ToolRiskLevel, ToolStdioCommandTemplate, UpdateToolApplication, UpdateToolConnection, PutToolConnectionInstalls, VercelConnectCredentialReference } from "@paperclipai/shared"; +import { CLASS3_STATIC_LEASE_ALLOWLIST, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppDefinitionForUrl, getAvailableConnectionMethods, getConnectableAppDefinition, isGoogleWorkspaceConnectorProfileId, isToolConnectionAttentionHealth, recommendedDefaultsForApp, type GoogleWorkspaceConnectorProfileId } from "@paperclipai/shared"; import { checkMcpRemoteHeaderName, checkMcpRemoteHeaderValue, @@ -157,16 +154,7 @@ import { createToolRuntimeSupervisor } from "./tool-runtime-supervisor.js"; import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared"; import { appWithPaperclipCloudConnectorAvailability, paperclipCloudConnectorCapabilitiesFromEnv, createPaperclipCloudConnector, isPaperclipCloudConnectorStrategy, paperclipCloudConnectorConfigFromEnv, type PaperclipCloudConnector } from "./paperclip-cloud-connector.js"; -import { - createVercelConnectClient, - deriveVercelConnectSubject, - vercelConnectCallbackUrl, - vercelConnectIntegrationStatus, - vercelGrantReference, - vercelTokenRequest, - VercelConnectClientError, - type VercelConnectClient, -} from "./vercel-connect.js"; +import { createVercelConnectClient, deriveVercelConnectSubject, vercelConnectIntegrationStatus, vercelTokenRequest, type VercelConnectClient } from "./vercel-connect.js"; const REMOTE_HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const MAX_REMOTE_HTTP_REDIRECTS = 5; @@ -653,77 +641,6 @@ function withoutInlineOAuthTokens( return changed ? { ...config, oauth: next } : config; } -function actorBinding(actor: ActorInfo | undefined) { - return { - actorType: actor?.actorType ?? null, - actorId: actor?.actorId ?? null, - sessionId: - typeof actor?.sessionId === "string" && actor.sessionId.trim().length > 0 - ? actor.sessionId - : null, - }; -} - -function oauthActorType(value: string | null): ActorInfo["actorType"] | null { - return value === "agent" || - value === "user" || - value === "system" || - value === "plugin" - ? value - : null; -} - -function assertSameOAuthActor( - stateRow: typeof toolOauthStates.$inferSelect, - actor: ActorInfo | undefined, -) { - const expected = { - actorType: oauthActorType(stateRow.createdByActorType), - actorId: stateRow.createdByActorId, - sessionId: stateRow.createdBySessionId, - }; - const actual = actorBinding(actor); - if (!expected.actorType || !expected.actorId) { - throw forbidden( - "OAuth sign-in state is not bound to an authenticated board session", - ); - } - if ( - expected.actorType !== actual.actorType || - expected.actorId !== actual.actorId - ) { - throw forbidden( - "OAuth sign-in must be completed by the user who started it", - ); - } - if (expected.sessionId && expected.sessionId !== actual.sessionId) { - throw forbidden( - "OAuth sign-in must be completed from the same authenticated session", - ); - } -} - -function toApplication( - row: typeof toolApplications.$inferSelect, -): ToolApplication { - return { - id: row.id, - companyId: row.companyId, - applicationKey: row.applicationKey ?? undefined, - name: row.name, - description: row.description, - type: row.type, - status: row.status, - pluginId: row.pluginId, - ownerAgentId: row.ownerAgentId, - ownerUserId: row.ownerUserId, - metadata: row.metadata ?? null, - archivedAt: row.archivedAt, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; -} - function assertClass3ToolCredentialRefAllowed(ref: { configPath?: string | null; projectionClass?: string | null; @@ -1454,6 +1371,7 @@ export function toolAccessService( const policySvc = toolAccessPolicyService(db); const now = options.now ?? (() => new Date()); const audit = createToolAccessAudit(db); + const ensureDefaultOrganizationGrant = createOrganizationGrantStore(db); const { mintConnectionTokenForAgent, loadBrokerRunContext } = createConnectionTokenBroker(db, { now, secrets, policySvc, audit, getConnectionRow, ensureDefaultOrganizationGrant }); const profileService = createToolProfileService(db, now); const profileDetails = profileService.getProfile; @@ -1498,6 +1416,17 @@ export function toolAccessService( const { resolveVercelCredentialHeaders, vercelGrantForConnection } = vercelCredentials; const credentialAccess = createConnectionCredentialAccess(db, { secrets, audit, vercel: vercelCredentials, resolveOAuthGrantSecret, maybeRefreshOAuthCredentials, refreshOAuthGrantCredentials }); const { vaultGrantForConnection, resolveCredentialHeaders, resolvedRemoteEndpoint } = credentialAccess; + const setupPolicy = createConnectionSetupPolicy(db); + const { assertConnectorConnectionSetupEnabled, assertExperimentalConnectorSetupEnabled } = setupPolicy; + const fixedPersonalIdentityForReconnect = createPersonalConnectionIdentity(db); + const oauthState = createOAuthStateStore(db); + const { peekOAuthState } = oauthState; + const finishOAuthCatalogWithRecommendedDefaults = createOAuthCatalogCompletion(db, finishGalleryAppConnection); + const startOAuth = createOAuthAuthorizationStart(db, { now, getConnectionRow, setupPolicy, fixedPersonalIdentityForReconnect, currentCloudConnector, vercelConnect, ensureOAuthClient, oauthEndpointsForConnection, assertOAuthEndpointUrl }); + const completeOAuthCallback = createDirectOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, setupPolicy, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, checkConnectionHealth, ensureDefaultOrganizationGrant, oauthEndpointsForConnection, exchangeOAuthToken }); + const completePaperclipCloudConnectorCallback = createCloudOAuthCallback(db, { now, getConnectionRow, state: oauthState, store: credentialStore, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, currentCloudConnector }); + const completeVercelConnectCallback = createVercelOAuthCallback(db, { now, getConnectionRow, state: oauthState, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, vercelConnect }); + const finalizeOAuthAccess = createOAuthAccessFinalization(db, { getConnectionRow, secrets, store: credentialStore, assertAgentsInCompany, finishGalleryAppConnection }); const runtimeSlots = createToolRuntimeSlots(db, runtimeSupervisor); const { ensureRuntimeSlot } = runtimeSlots; const runtimeHealth = createToolRuntimeHealth(db, options, now, trustedRuntimeHost); @@ -2503,106 +2432,6 @@ export function toolAccessService( return row; } - async function ensureDefaultOrganizationGrant( - connection: typeof toolConnections.$inferSelect, - dbClient: ToolAccessMutationDb = db, - onMutation?: (mutation: { - previous: typeof connectionGrants.$inferSelect | null; - current: typeof connectionGrants.$inferSelect; - }) => void, - /** - * Scope provenance from the authorization that just completed, for the callers that have - * one. The default organization grant is created before OAuth has issued any credentials, - * so without this the grants API reads back a shared identity whose scope has no source - * and no over-grant warning — while the connection record carries both. - * - * Deliberately scope fields only. The shared grant takes its credential lifecycle from the - * connection, and writing `accessTokenExpiresAt` here would make an expired connection - * look fresh to the refresh-due check and skip the reconnect prompt. - */ - oauthProvenance?: { - scopes: string[]; - scopeSource: "provider" | "requested_fallback"; - unrequestedScopes: string[]; - requestedScopes: string[]; - }, - ) { - const providerTenantWithOauth = ( - current: (typeof connectionGrants.$inferSelect)["providerTenant"], - ) => - oauthProvenance - ? { - ...(current ?? {}), - oauth: { - ...(current?.oauth ?? {}), - scopes: oauthProvenance.scopes, - scopeSource: oauthProvenance.scopeSource, - unrequestedScopes: oauthProvenance.unrequestedScopes, - requestedScopes: oauthProvenance.requestedScopes, - }, - } - : current; - const [existing] = await dbClient - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "organization"), - eq(connectionGrants.isDefault, true), - ), - ) - .limit(1); - if (existing) { - // OAuth connections create their organization grant before the browser - // callback has issued any credentials. Reconnects can rotate those - // credentials later as well. Keep the execution grant synchronized with - // the connection so the gateway projects the current secrets instead of - // sending an unauthenticated request after an apparently successful - // setup. Reconnecting is also the explicit recovery path for a revoked - // shared identity, so it is correct to reactivate that default grant here. - const [updated] = await dbClient - .update(connectionGrants) - .set({ - credentialSecretRefs: isRailwayConnection(connection) - ? [...connection.credentialSecretRefs, ...existing.credentialSecretRefs.filter((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH && !connection.credentialSecretRefs.some((candidate) => candidate.configPath === ref.configPath))] - : connection.credentialSecretRefs, - ...(oauthProvenance - ? { providerTenant: providerTenantWithOauth(existing.providerTenant) } - : {}), - status: "active", - revokedAt: null, - revokedByAgentId: null, - revokedByUserId: null, - updatedAt: new Date(), - }) - .where(eq(connectionGrants.id, existing.id)) - .returning(); - if (!updated) - throw new Error("Failed to update default connection grant"); - onMutation?.({ previous: existing, current: updated }); - return updated; - } - const [created] = await dbClient - .insert(connectionGrants) - .values({ - companyId: connection.companyId, - connectionId: connection.id, - kind: "organization", - credentialSecretRefs: connection.credentialSecretRefs, - ...(oauthProvenance - ? { providerTenant: providerTenantWithOauth(null) } - : {}), - status: "active", - isDefault: true, - }) - .returning(); - if (!created) throw new Error("Failed to create default connection grant"); - onMutation?.({ previous: null, current: created }); - return created; - } - /** * Split the secrets a connection points at into the ones it owns outright and * the ones another consumer still depends on. @@ -3553,12 +3382,6 @@ export function toolAccessService( await agentmailApi(key).whoami(); } - function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) { - if (isRetiredComposioConnection(connection)) { - throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" }); - } - } - async function discoverTools( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, @@ -4790,43 +4613,6 @@ export function toolAccessService( }; } - function actionSummary( - entry: ToolCatalogEntry, - ): ToolAppConnectionActionSummary { - return { - catalogEntryId: entry.id, - toolName: entry.toolName, - title: entry.title, - description: entry.description, - riskLevel: entry.riskLevel, - isReadOnly: entry.isReadOnly, - isWrite: entry.isWrite, - isDestructive: entry.isDestructive, - status: entry.status, - }; - } - - function groupedActions( - catalog: ToolCatalogEntry[], - ): ConnectToolAppResult["actions"] { - const readOnly: ToolAppConnectionActionSummary[] = []; - const canMakeChanges: ToolAppConnectionActionSummary[] = []; - for (const entry of catalog) { - const summary = actionSummary(entry); - if ( - entry.isReadOnly && - entry.riskLevel === "read" && - !entry.isWrite && - !entry.isDestructive - ) { - readOnly.push(summary); - } else { - canMakeChanges.push(summary); - } - } - return { readOnly, canMakeChanges }; - } - function defaultLinkName(link: string): string { try { const url = new URL(link); @@ -4902,79 +4688,6 @@ export function toolAccessService( return fields; } - function base64UrlSha256(input: string) { - return createHash("sha256").update(input).digest("base64url"); - } - - function randomOauthToken(bytes = 32) { - return randomBytes(bytes).toString("base64url"); - } - - function assertOAuthRedirectConstraints( - app: AppDefinition | null, - redirectUri: string, - ) { - if (app?.redirectConstraints !== "https-or-loopback-http") return; - let redirect: URL; - try { - redirect = new URL(redirectUri); - } catch { - throw unprocessable("OAuth callback URL is invalid", { - code: "oauth_redirect_uri_invalid", - }); - } - const hostname = redirect.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - const isLoopback = - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname === "::1" || - /^127(?:\.\d{1,3}){3}$/.test(hostname); - if ( - redirect.protocol === "https:" || - (redirect.protocol === "http:" && isLoopback) - ) - return; - throw unprocessable( - "This provider requires an HTTPS or loopback origin. Configure TLS before connecting.", - { - code: "oauth_redirect_origin_unsupported", - redirectConstraints: app.redirectConstraints, - docsPath: "docs/deploy", - }, - ); - } - - /** - * Validate RFC 9207 `iss` on the authorization callback. - * - * When an authorization server returns `iss`, it must name the same issuer the - * authorization request was bound to. A mismatch means the code came back from a - * different server than the one we sent the user to — the mix-up attack RFC 9207 - * exists to stop — so the code is refused rather than exchanged. An absent `iss` - * is tolerated: it is optional, and many deployed servers omit it. - */ - function assertOAuthCallbackIssuer( - connection: typeof toolConnections.$inferSelect, - endpoints: OAuthProviderEndpoints, - iss: string | null | undefined, - ) { - const returnedIssuer = typeof iss === "string" ? iss.trim() : ""; - if (!returnedIssuer) return; - const oauth = oauthConfig(connection); - const expectedIssuer = - typeof oauth.expectedIssuer === "string" && oauth.expectedIssuer.trim() - ? oauth.expectedIssuer.trim() - : (endpoints.issuer ?? null); - if (!expectedIssuer) return; - if (sameOAuthIssuer(returnedIssuer, expectedIssuer)) return; - throw badRequest( - "Sign-in came back from an unexpected server. Start the connection again.", - { - code: "oauth_issuer_mismatch", - }, - ); - } - function policyNameForApp( connection: typeof toolConnections.$inferSelect, entry: typeof toolCatalogEntries.$inferSelect, @@ -5001,32 +4714,6 @@ export function toolAccessService( return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; } - async function assertExperimentalConnectorSetupEnabled(provider: unknown, existing = false) { - const app = typeof provider === "string" ? getConnectableAppDefinition(provider) : null; - if (!app?.experimentalFeatureKey && (existing || !isMemoryConnectorId(provider))) return; - const settings = await instanceSettingsService(db).getExperimental(); - if (app && !isConnectorFeatureEnabled(app, settings)) { - throw forbidden(`${app.name} is unavailable on this instance`, { code: "connector_disabled" }); - } - if (!existing && isMemoryConnectorId(provider) && !settings.enableMemoryConnectors) { - throw forbidden("Enable memory connectors in Settings → Experimental to set up this connection", { code: "memory_connectors_disabled" }); - } - } - - async function assertConnectorConnectionSetupEnabled(connection: { config?: Record; transportConfig?: Record; applicationId?: string; companyId?: string; status?: string }, applicationKey?: unknown) { - let resolvedApplicationKey = applicationKey; - if (resolvedApplicationKey == null && connection.applicationId && connection.companyId) { - const [application] = await db.select({ applicationKey: toolApplications.applicationKey }) - .from(toolApplications).where(and(eq(toolApplications.id, connection.applicationId), - eq(toolApplications.companyId, connection.companyId))).limit(1); - resolvedApplicationKey = application?.applicationKey; - } - const marker = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey; - const app = resolvedApplicationKey != null ? getAppDefinitionForApplicationKey(resolvedApplicationKey) - : typeof marker === "string" ? getConnectableAppDefinition(marker) : null; - if (app) await assertExperimentalConnectorSetupEnabled(app.slug, connection.status !== "draft"); - } - async function connectGalleryApp( companyId: string, input: ConnectToolApp, @@ -7061,74 +6748,6 @@ export function toolAccessService( }; } - /** - * Resolve the one user identity a personal-only connection is allowed to - * refresh. The connection creator is authoritative for new rows; retained - * grants cover older or agent-created rows. Reconnect may rotate that - * identity's credential, but it may never create a different user's identity - * on the same connection. - */ - async function fixedPersonalIdentityForReconnect( - connection: typeof toolConnections.$inferSelect, - requestedSubjectUserId: string | undefined, - actor?: ActorInfo, - ): Promise<{ - subjectUserId: string; - grant: typeof connectionGrants.$inferSelect | null; - } | null> { - if (connection.credentialPolicy !== "per_user") return null; - - const personalGrants = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - ), - ) - .orderBy(desc(connectionGrants.updatedAt)); - const creatorGrant = connection.createdByUserId - ? (personalGrants.find( - (grant) => grant.subjectUserId === connection.createdByUserId, - ) ?? null) - : null; - const retainedGrant = - creatorGrant ?? - personalGrants.find((grant) => grant.credentialSecretRefs.length > 0) ?? - personalGrants[0] ?? - null; - const fixedSubjectUserId = - connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null; - const binding = actorBinding(actor); - const actorUserId = binding.actorType === "user" ? binding.actorId : null; - const subjectUserId = requestedSubjectUserId ?? actorUserId; - - if (!subjectUserId) { - throw forbidden( - "Reconnect this personal connection as the user it belongs to", - ); - } - if (actorUserId && subjectUserId !== actorUserId) { - throw forbidden( - "Board users may only reconnect their own personal connection", - ); - } - if (fixedSubjectUserId && subjectUserId !== fixedSubjectUserId) { - throw forbidden( - "Only the existing personal identity can reconnect this connection", - ); - } - - return { - subjectUserId, - grant: - personalGrants.find((grant) => grant.subjectUserId === subjectUserId) ?? - null, - }; - } - /** * Replace the credential(s) on an existing connection and re-run the health * check — the "Replace key" / reconnect flow (M7, PAP-10859). Rotates the @@ -7312,2647 +6931,6 @@ export function toolAccessService( return { ...health, connection: refresh.connection }; } - async function startOAuth( - companyId: string, - connectionId: string, - input: { - redirectUri: string; - actor: ActorInfo; - subjectUserId?: string; - subjectAgentId?: string; - scopes?: string[]; - workspaceServices?: GoogleWorkspaceServiceSelection; - returnTo?: string; - issueId?: string; - interactionId?: string; - }, - ): Promise { - let connection = await getConnectionRow(connectionId, companyId); - assertSupportedConnection(connection); - await assertConnectorConnectionSetupEnabled(connection); - await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); - if (connection.status === "archived") - throw conflict("Archived app connections cannot start sign in"); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - assertOAuthRedirectConstraints(galleryEntry, input.redirectUri); - const galleryMethod = galleryEntry - ? connectionMethodForConnection(galleryEntry, connection) - : null; - const requestedScopes = await (async () => { - if (input.workspaceServices && sourceTemplateKey !== "google-workspace") throw badRequest("Service selection requires Google Workspace"); - if (sourceTemplateKey === "google-workspace") { - if (input.workspaceServices && input.scopes) throw badRequest("Choose services or scopes, not both"); - const allowed = normalizeOauthScopes(galleryMethod?.defaults?.scopesHint); - let scopes = input.scopes ?? (input.workspaceServices ? googleWorkspaceSelectedScopes(input.workspaceServices) : undefined); - if (!scopes && connection.status !== "draft") { - // Account-page reconnect has no new service choice. Never replace its - // subject's saved request with the new-account write defaults. - const binding = actorBinding(input.actor); - const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null); - const kind = connection.credentialPolicy === "per_user" ? "user" - : connection.credentialPolicy === "per_agent" ? "agent" : "organization"; - const [grant] = await db.select().from(connectionGrants).where(and( - eq(connectionGrants.companyId, companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, kind), - kind === "user" ? eq(connectionGrants.subjectUserId, subjectUserId ?? "") - : kind === "agent" ? eq(connectionGrants.subjectAgentId, input.subjectAgentId ?? "") - : eq(connectionGrants.isDefault, true), - )).limit(1); - const saved = grant?.providerTenant?.oauth; - scopes = normalizeOauthScopes(saved?.requestedScopes ?? saved?.scopes) - .filter(scope => allowed.includes(scope)); - if (scopes.length === 0) throw badRequest("Choose your Google Workspace services before reconnecting"); - } - scopes ??= googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); - if (!isGoogleWorkspaceScopeGrant(scopes) || scopes.some(scope => !allowed.includes(scope))) throw badRequest("Select at least one supported Google service"); - return scopes; - } - if (!galleryMethod) return input.scopes ?? null; - const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint); - if (!input.scopes) return allowed; - const requested = normalizeOauthScopes(input.scopes); - const widened = requested.filter((scope) => !allowed.includes(scope)); - if (widened.length > 0) { - throw badRequest( - `Requested OAuth scopes are not allowed for ${galleryEntry?.name ?? "this app"}`, - { - code: "oauth_scope_widening_rejected", - scopes: widened, - }, - ); - } - return requested; - })(); - const starterBinding = actorBinding(input.actor); - const fixedPersonalIdentity = await fixedPersonalIdentityForReconnect( - connection, - input.subjectUserId, - input.actor, - ); - const authorizationSubjectUserId = - fixedPersonalIdentity?.subjectUserId ?? input.subjectUserId; - const authorizationSubjectAgentId = input.subjectAgentId; - if (authorizationSubjectAgentId) { - const [subjectAgent] = await db - .select({ id: agents.id }) - .from(agents) - .where( - and( - eq(agents.id, authorizationSubjectAgentId), - eq(agents.companyId, companyId), - ), - ) - .limit(1); - if (!subjectAgent) - throw badRequest( - "Dedicated identity requires an agent in this company", - ); - if (connection.credentialPolicy !== "per_agent") { - throw badRequest( - "This connection is not configured for a dedicated agent identity", - ); - } - } - const intentLink = input.interactionId - ? await db - .select({ - id: issueThreadInteractions.id, - issueId: issueThreadInteractions.issueId, - addresseeUserId: issueThreadInteractions.addresseeUserId, - kind: issueThreadInteractions.kind, - status: issueThreadInteractions.status, - }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.id, input.interactionId), - eq(issueThreadInteractions.companyId, companyId), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null) - : null; - if ( - input.interactionId && - (!intentLink || - intentLink.kind !== "connection_intent" || - intentLink.status !== "pending" || - starterBinding.actorType !== "user" || - starterBinding.actorId !== intentLink.addresseeUserId) - ) { - throw forbidden( - "Only the addressed user can authorize this connection request", - ); - } - if (connection.credentialSource === "vercel_connect") { - if (!vercelConnect) - throw vercelConnectHttpError( - new VercelConnectClientError("vercel_connect_unavailable", 503), - ); - const credential = vercelCredentialFor(connection); - if ( - connection.authKind !== "oauth" || - credential.principalMode !== "user" - ) { - throw badRequest( - "This Vercel connector does not use browser authorization", - ); - } - const binding = starterBinding; - if (!binding.actorType || !binding.actorId) { - throw forbidden( - "Vercel Connect authorization requires an authenticated actor", - ); - } - const grantKind: ConnectionGrantKind = authorizationSubjectUserId - ? "user" - : "organization"; - const derived = deriveVercelConnectSubject({ - credential, - connectionId: connection.id, - companyId, - grantKind, - subjectUserId: authorizationSubjectUserId, - }); - const state = randomOauthToken(); - let authorization; - try { - authorization = await vercelConnect.startAuthorization( - { - connector: credential.connectorUid, - subject: derived.subject, - scopes: credential.scopes, - resources: vercelConnectResourcesFor(connection), - }, - vercelConnectCallbackUrl(input.redirectUri, state), - ); - } catch (error) { - throw vercelConnectHttpError(error); - } - const remoteExpiry = authorization.expiresAt - ? new Date(authorization.expiresAt) - : null; - const expiresAt = - remoteExpiry && Number.isFinite(remoteExpiry.getTime()) - ? new Date( - Math.min( - remoteExpiry.getTime(), - now().getTime() + 10 * 60 * 1000, - ), - ) - : new Date(now().getTime() + 10 * 60 * 1000); - await db - .delete(toolOauthStates) - .where(lt(toolOauthStates.expiresAt, now())); - await db.insert(toolOauthStates).values({ - state, - companyId, - connectionId: connection.id, - // Vercel owns its verifier. This marker only dispatches the consumed - // one-time state to the Vercel callback completion path. - codeVerifier: "vercel-connect", - createdByActorType: binding.actorType, - createdByActorId: binding.actorId, - createdBySessionId: binding.sessionId, - subjectUserId: authorizationSubjectUserId, - requestedScopes: [...credential.scopes], - returnTo: input.returnTo, - issueId: intentLink?.issueId ?? input.issueId, - interactionId: intentLink?.id, - expiresAt, - }); - return { - connectionId: connection.id, - provider: `vercel-connect:${credential.service}`, - authorizationUrl: authorization.url, - expiresAt: expiresAt.toISOString(), - issuer: "https://vercel.com/connect", - resource: galleryMethod?.defaults?.serverUrl ?? null, - registrationSource: null, - }; - } - if ( - galleryMethod && - isPaperclipCloudConnectorStrategy(galleryMethod.oauthStrategy) - ) { - const connectorProfile = galleryMethod.connectorProfile; - const managedProfile = managedConnectorProfile(connectorProfile); - if (!managedProfile) - throw badRequest("This app has an invalid managed connector profile"); - const providerName = galleryEntry?.name ?? "Google Workspace"; - const cloudConnector = currentCloudConnector(); - if (!cloudConnector) { - throw unprocessable( - `${providerName} connections through Paperclip are not available on this instance yet`, - { - code: "paperclip_cloud_connector_unavailable", - }, - ); - } - const binding = starterBinding; - if (!binding.actorType || !binding.actorId) { - throw forbidden( - `${providerName} sign-in requires an authenticated actor`, - ); - } - const subjectUserId = authorizationSubjectAgentId - ? null - : (authorizationSubjectUserId ?? - (binding.actorType === "user" ? binding.actorId : null)); - if (!subjectUserId && !authorizationSubjectAgentId) { - throw forbidden( - `Agent-started ${providerName} sign-in requires an authorized identity`, - ); - } - if ( - !authorizationSubjectAgentId && - binding.actorType === "user" && - subjectUserId !== binding.actorId - ) { - throw forbidden( - `Board users may only authorize their own ${providerName} identity`, - ); - } - await db - .delete(toolOauthStates) - .where(lt(toolOauthStates.expiresAt, now())); - const state = randomOauthToken(); - const returnUri = new URL(input.redirectUri); - returnUri.pathname = "/api/tools/oauth/cloud-connector/callback"; - returnUri.search = ""; - returnUri.hash = ""; - const session = await cloudConnector.startAuthorization({ - subject: authorizationSubjectAgentId - ? `agent:${authorizationSubjectAgentId}` - : subjectUserId!, - companyId, - profile: managedProfile.id, - ...(managedProfile.id === "workspace.all" ? { scopes: requestedScopes! } : {}), - returnUri: returnUri.toString(), - returnState: state, - }); - const remoteExpiry = new Date(session.expiresAt); - const expiresAt = Number.isFinite(remoteExpiry.getTime()) - ? new Date( - Math.min(remoteExpiry.getTime(), now().getTime() + 10 * 60 * 1000), - ) - : new Date(now().getTime() + 10 * 60 * 1000); - await db.insert(toolOauthStates).values({ - state, - companyId, - connectionId: connection.id, - // Paperclip Cloud owns PKCE for this flow. The local state row remains the - // single-use browser correlator and never stores broker token material. - codeVerifier: "paperclip-cloud-connector", - createdByActorType: binding.actorType, - createdByActorId: binding.actorId, - createdBySessionId: binding.sessionId, - subjectUserId, - subjectAgentId: authorizationSubjectAgentId, - requestedScopes: managedProfile.id === "workspace.all" ? requestedScopes! : [...managedProfile.scopes], - returnTo: input.returnTo, - issueId: intentLink?.issueId ?? input.issueId, - interactionId: intentLink?.id, - expiresAt, - }); - return { - connectionId: connection.id, - provider: managedProfile.provider, - authorizationUrl: session.authorizationUrl, - expiresAt: expiresAt.toISOString(), - ...(session.handoff ? { handoff: session.handoff } : {}), - issuer: - managedProfile.provider === "github" - ? "https://github.com" - : managedProfile.provider === "asana" - ? "https://app.asana.com" - : "https://accounts.google.com", - resource: galleryMethod.defaults?.serverUrl ?? null, - registrationSource: null, - }; - } - const endpoints = await oauthEndpointsForConnection( - connection, - null, - input.redirectUri, - ); - if (endpoints.grantType === "client_credentials") { - throw unprocessable( - "This app uses shared machine credentials and does not need browser sign in", - ); - } - const resolvedClient = await ensureOAuthClient({ - connection, - endpoints, - redirectUri: input.redirectUri, - galleryEntry, - actor: input.actor, - }); - 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}`, - ); - - const authorizationScopes = normalizeOauthScopes( - galleryMethod ? (requestedScopes ?? []) : (input.scopes ?? endpoints.scopes), - ); - if ( - !galleryMethod && - endpoints.offlineAccessSupported && - (!endpoints.grantTypesSupported?.length || - endpoints.grantTypesSupported.includes("refresh_token")) && - !authorizationScopes.includes("offline_access") - ) authorizationScopes.push("offline_access"); - - await db - .delete(toolOauthStates) - .where(lt(toolOauthStates.expiresAt, new Date())); - - const state = randomOauthToken(); - const codeVerifier = randomOauthToken(48); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); - const binding = starterBinding; - if (!binding.actorType || !binding.actorId) { - throw forbidden("OAuth sign-in requires an authenticated board session"); - } - await db.insert(toolOauthStates).values({ - state, - companyId, - connectionId: connection.id, - codeVerifier, - createdByActorType: binding.actorType, - createdByActorId: binding.actorId, - createdBySessionId: binding.sessionId, - subjectUserId: authorizationSubjectUserId, - requestedScopes: authorizationScopes, - returnTo: input.returnTo, - issueId: intentLink?.issueId ?? input.issueId, - interactionId: intentLink?.id, - expiresAt, - }); - - // Last gate before this URL becomes a top-level browser navigation. Every - // producer above already validates, so reaching a rejection here means a new - // path was added without one — fail closed rather than hand the board an - // unvetted target. - const authorizationUrl = new URL( - assertOAuthEndpointUrl("authorization", endpoints.authorizationUrl, { - // Paperclip's own callback origin: a first-party authorization endpoint is - // served however this deployment is served, plaintext LAN host included. - firstPartyOrigin: originOf(input.redirectUri), - }), - ); - authorizationUrl.searchParams.set("response_type", "code"); - authorizationUrl.searchParams.set("client_id", client.clientId); - authorizationUrl.searchParams.set("redirect_uri", input.redirectUri); - authorizationUrl.searchParams.set("state", state); - authorizationUrl.searchParams.set( - "code_challenge", - base64UrlSha256(codeVerifier), - ); - authorizationUrl.searchParams.set("code_challenge_method", "S256"); - // RFC 8707: name the MCP server the resulting token is for, so an - // authorization server that serves several resources can audience-restrict it. - if (endpoints.resource) - authorizationUrl.searchParams.set("resource", endpoints.resource); - // Curated definitions are an allowlist, not a suggestion. Never copy every - // scope advertised by discovery into a provider consent screen: a curated - // method either sends its reviewed hint or omits scope entirely. Generic - // MCP URLs retain discovery-first behavior because Paperclip has no manifest - // against which it could safely judge the caller's requested scope. - if (authorizationScopes.length > 0) - authorizationUrl.searchParams.set("scope", authorizationScopes.join(" ")); - if (galleryMethod && connection.config.sourceTemplateKey === "google-workspace") - authorizationUrl.searchParams.set("enable_granular_consent", "true"); - const reviewedAuthorizationParams = - galleryMethod?.defaults?.oauthAuthorizationParams; - if (reviewedAuthorizationParams?.access_type) - authorizationUrl.searchParams.set( - "access_type", - reviewedAuthorizationParams.access_type, - ); - if (reviewedAuthorizationParams?.prompt) - authorizationUrl.searchParams.set( - "prompt", - reviewedAuthorizationParams.prompt, - ); - else if (!galleryMethod && authorizationScopes.includes("offline_access")) - // OIDC offline access requires consent unless the provider has another - // established basis for granting it. A generic server has no reviewed override. - authorizationUrl.searchParams.set("prompt", "consent"); - - if ( - authorizationSubjectUserId && - input.issueId && - binding.actorType === "agent" - ) { - const idempotencyKey = `connection-authorization:${connection.id}:${authorizationSubjectUserId}`; - // Provider label for the card's copy. The gallery definition's name when we - // have one, else the connection's own name — never a secret name or ref. - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const providerName = - (sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey)?.name - : null) ?? connection.name; - const [requestingAgent] = binding.actorId - ? await db - .select({ name: agents.name }) - .from(agents) - .where( - and( - eq(agents.id, binding.actorId), - eq(agents.companyId, companyId), - ), - ) - .limit(1) - : [undefined]; - const payload = { - version: 1 as const, - prompt: `Connect your ${providerName} to continue`, - acceptLabel: `Connect ${providerName}`, - rejectLabel: "Not now", - detailsMarkdown: - "Authorization is required before this agent can act on your behalf.", - // Presentation metadata so the card can compose its own copy instead of - // parsing the title string (PAP-17835 seam #6). The interaction kind and - // the server-addressed audience are unchanged. - connectionAuthorization: { - version: 1 as const, - providerName, - connectionName: - connection.name === providerName ? null : connection.name, - requestingAgentName: requestingAgent?.name ?? null, - }, - target: { - type: "custom" as const, - key: `connection:${connection.uid}:user:${authorizationSubjectUserId}`, - revisionId: state, - label: `Connect ${providerName}`, - href: authorizationUrl.toString(), - }, - }; - const [existingInteraction] = await db - .select() - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.companyId, companyId), - eq(issueThreadInteractions.issueId, input.issueId), - eq(issueThreadInteractions.idempotencyKey, idempotencyKey), - ), - ) - .limit(1); - const [interaction] = existingInteraction - ? await db - .update(issueThreadInteractions) - .set({ - status: "pending", - requestedResolverPolicy: "human_only", - effectiveResolverPolicy: "human_only", - resolverPolicyProvenance: "explicit", - effectiveResolverPolicySource: "requested", - addresseeUserId: authorizationSubjectUserId, - payload, - result: null, - resolvedAt: null, - updatedAt: new Date(), - }) - .where(eq(issueThreadInteractions.id, existingInteraction.id)) - .returning() - : await db - .insert(issueThreadInteractions) - .values({ - companyId, - issueId: input.issueId, - kind: "request_confirmation", - status: "pending", - continuationPolicy: "none", - requestedResolverPolicy: "human_only", - effectiveResolverPolicy: "human_only", - resolverPolicyProvenance: "explicit", - effectiveResolverPolicySource: "requested", - addresseeUserId: authorizationSubjectUserId, - idempotencyKey, - sourceRunId: - binding.actorType === "agent" - ? (input.actor.sessionId ?? null) - : null, - title: `Connect your ${providerName} to continue`, - summary: `${requestingAgent?.name ?? "An agent"} needs your ${providerName} identity for work running as you.`, - createdByAgentId: - binding.actorType === "agent" ? binding.actorId : null, - payload, - }) - .returning(); - if (interaction) { - await db - .update(toolOauthStates) - .set({ interactionId: interaction.id }) - .where(eq(toolOauthStates.state, state)); - } - } - - const nextConfig = { - ...connection.config, - oauth: { - ...oauthConfig(connection), - provider: endpoints.provider, - authorizationUrl: endpoints.authorizationUrl, - tokenUrl: endpoints.tokenUrl, - registrationUrl: endpoints.registrationUrl ?? null, - metadataUrl: endpoints.metadataUrl ?? null, - // Curated apps persist only the reviewed scopes attached to this OAuth - // state. Discovery metadata can advertise a provider's entire scope - // universe and must never silently become Paperclip's requested set. - scopes: authorizationScopes, - offlineAccessSupported: endpoints.offlineAccessSupported, - grantTypesSupported: endpoints.grantTypesSupported ?? [], - codeChallengeMethodsSupported: - endpoints.codeChallengeMethodsSupported ?? [], - tokenEndpointAuthMethodsSupported: - endpoints.tokenEndpointAuthMethodsSupported ?? [], - grantType: "authorization_code", - clientIdEnv: client.clientIdEnv, - clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null, - credentialScope: credentialScope(connection, input.actor), - // Persist the issuer and resource this authorization run is bound to. - // `iss` on the callback is validated against `expectedIssuer`, and - // refresh/reconnect/revoke reuse the same pair rather than re-deriving it. - issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, - expectedIssuer: endpoints.issuer ?? null, - resource: - endpoints.resource ?? oauthConfig(connection).resource ?? null, - clientIdMetadataDocumentSupported: - endpoints.clientIdMetadataDocumentSupported === true, - }, - }; - await db - .update(toolConnections) - .set({ - // A generic URL connection starts life as `authKind: none`. Once it has - // completed OAuth discovery and client resolution it is an OAuth - // connection, and refresh, reconnect, revoke and diagnostics must all - // treat it as one. - authKind: "oauth", - config: nextConfig, - transportConfig: nextConfig, - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, connection.id)); - - return { - connectionId: connection.id, - provider: endpoints.provider, - authorizationUrl: authorizationUrl.toString(), - expiresAt: expiresAt.toISOString(), - issuer: endpoints.issuer ?? null, - resource: endpoints.resource ?? null, - registrationSource: resolvedClient.source, - }; - } - - async function peekOAuthState(state: string) { - const [row] = await db - .select({ - companyId: toolOauthStates.companyId, - connectionId: toolOauthStates.connectionId, - subjectUserId: toolOauthStates.subjectUserId, - subjectAgentId: toolOauthStates.subjectAgentId, - returnTo: toolOauthStates.returnTo, - issueId: toolOauthStates.issueId, - interactionId: toolOauthStates.interactionId, - }) - .from(toolOauthStates) - .where(eq(toolOauthStates.state, state)) - .limit(1); - return row ?? null; - } - - /** Bind a callback to its initiating actor without consuming retryable state. */ - async function validateOAuthState( - state: string, - actor: ActorInfo | undefined, - ) { - const [stateRow] = await db - .select() - .from(toolOauthStates) - .where(eq(toolOauthStates.state, state)) - .limit(1); - if (!stateRow) - throw badRequest("OAuth state was not found or has already been used"); - if (stateRow.expiresAt.getTime() <= Date.now()) - throw badRequest("OAuth state has expired"); - if (stateRow.subjectUserId) { - if ( - actor?.actorType !== "user" || - actor.actorId !== stateRow.subjectUserId - ) { - throw forbidden( - "OAuth callback user does not match the requested subject", - ); - } - } else { - assertSameOAuthActor(stateRow, actor); - } - return stateRow; - } - - /** - * Answer a terminal authorization callback exactly once. Actor validation - * happens before the atomic delete, so an unbound callback cannot consume a - * valid flow and concurrent callbacks cannot both complete it. - */ - async function consumeOAuthState( - state: string, - actor: ActorInfo | undefined, - ) { - const stateRow = await validateOAuthState(state, actor); - const [consumed] = await db - .delete(toolOauthStates) - .where(eq(toolOauthStates.state, state)) - .returning(); - if (!consumed) - throw badRequest("OAuth state was not found or has already been used"); - return consumed; - } - - /** - * End the board's "Connect your account" prompt when the user declines in the - * provider's window (PAP-17109). Without this the card stays `pending`, so the - * board keeps offering an authorization link for a flow the user just refused - * and the requesting agent never learns the answer. - * - * Scoped to a still-`pending` row: a board user who already answered the card - * directly keeps their own answer. - */ - async function rejectPendingOAuthInteraction( - stateRow: typeof toolOauthStates.$inferSelect, - actor: ActorInfo | undefined, - ) { - if (!stateRow.interactionId) return; - const now = new Date(); - const linked = await db - .select({ - kind: issueThreadInteractions.kind, - payload: issueThreadInteractions.payload, - }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.id, stateRow.interactionId), - eq(issueThreadInteractions.companyId, stateRow.companyId), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (linked?.kind === "connection_intent") { - const connectionIntentPayload = connectionIntentPayloadSchema.parse( - linked.payload, - ); - await db - .update(issueThreadInteractions) - .set({ - payload: { ...connectionIntentPayload, phase: "needs_retry" }, - updatedAt: now, - }) - .where( - and( - eq(issueThreadInteractions.id, stateRow.interactionId), - eq(issueThreadInteractions.companyId, stateRow.companyId), - eq(issueThreadInteractions.status, "pending"), - ), - ); - return; - } - await db - .update(issueThreadInteractions) - .set({ - status: "rejected", - result: { - version: 1, - outcome: "rejected", - // Paperclip's own words: the provider's explanation is untrusted and - // this reason is rendered in the thread (PAP-17108). - reason: - "Authorization was declined or cancelled in the provider's window", - }, - resolvedByUserId: actor?.actorType === "user" ? actor.actorId : null, - resolvedAt: now, - updatedAt: now, - }) - .where( - and( - eq(issueThreadInteractions.id, stateRow.interactionId), - eq(issueThreadInteractions.companyId, stateRow.companyId), - eq(issueThreadInteractions.status, "pending"), - ), - ); - } - - async function finishOAuthCatalogWithRecommendedDefaults(input: { - connection: typeof toolConnections.$inferSelect; - catalog: ToolCatalogEntry[]; - suggestedDefaults: ConnectToolAppResult["suggestedDefaults"]; - activateQuarantined?: boolean; - actor?: ActorInfo; - interactionId?: string | null; - }) { - const linkedInteraction = input.interactionId - ? await db - .select({ kind: issueThreadInteractions.kind }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.id, input.interactionId), - eq(issueThreadInteractions.companyId, input.connection.companyId), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null) - : null; - // A task callback only prepares the catalog. The intent completion transaction - // validates current ownership and adds the requesting agent's access. - const deferTaskAccess = linkedInteraction?.kind === "connection_intent"; - const installs = await db - .select() - .from(toolConnectionInstalls) - .where( - and( - eq(toolConnectionInstalls.companyId, input.connection.companyId), - eq(toolConnectionInstalls.connectionId, input.connection.id), - ), - ); - const companyInstall = installs.some( - (install) => install.targetType === "company", - ); - const agentIds = installs - .filter((install) => install.targetType === "agent") - .map((install) => install.targetId); - const suggestedAccess = input.suggestedDefaults.access; - const suggestedAccessRecord = asRecord(suggestedAccess); - const suggestedAgentIds = Array.isArray(suggestedAccessRecord.agentIds) - ? suggestedAccessRecord.agentIds.filter( - (agentId): agentId is string => typeof agentId === "string", - ) - : []; - const normalizedSuggestedAccess: FinishToolApp["access"] = - suggestedAccess === "all_agents" - ? "all_agents" - : suggestedAgentIds.length > 0 - ? { agentIds: suggestedAgentIds } - : "all_agents"; - // Workspace setup saves the operator's install selection before OAuth too. - // An empty selection means no agents, not permission to apply all-agent - // defaults after the user returns from Google. - const preserveEmptyInstalls = input.connection.config.sourceTemplateKey === "google-workspace" - || isRemoteMcpConnectorMethod( - input.connection.config.sourceTemplateKey, - input.connection.config.connectionMethodKey, - ) || (input.connection.transport === "mcp_remote" - && input.connection.config.mcpAgentAccessConfigured === true); - const access: FinishToolApp["access"] = deferTaskAccess - ? { agentIds: [] } - : installs.length === 0 - ? preserveEmptyInstalls ? { agentIds: [] } : normalizedSuggestedAccess - : companyInstall - ? "all_agents" - : { agentIds }; - const askFirstRiskLevels = new Set( - Array.isArray(input.suggestedDefaults.askFirstRiskLevels) - ? input.suggestedDefaults.askFirstRiskLevels.filter( - (riskLevel): riskLevel is string => typeof riskLevel === "string", - ) - : [], - ); - const enabledCatalog = input.catalog.filter( - (entry) => - entry.status === "active" || - (input.activateQuarantined === true && entry.status === "quarantined"), - ); - const finished = await finishGalleryAppConnection( - input.connection.companyId, - input.connection.id, - { - enabledCatalogEntryIds: enabledCatalog.map((entry) => entry.id), - askFirstCatalogEntryIds: enabledCatalog - .filter((entry) => askFirstRiskLevels.has(entry.riskLevel)) - .map((entry) => entry.id), - reviewedCatalogEntryIds: - input.activateQuarantined === true - ? enabledCatalog - .filter((entry) => entry.status === "quarantined") - .map((entry) => entry.id) - : undefined, - access, - preserveExistingAccess: deferTaskAccess, - }, - input.actor, - ); - if (!deferTaskAccess && !preserveEmptyInstalls && installs.length === 0) { - const installTargets = - access === "all_agents" - ? [ - { - targetType: "company" as const, - targetId: input.connection.companyId, - }, - ] - : [...new Set(access.agentIds)].map((agentId) => ({ - targetType: "agent" as const, - targetId: agentId, - })); - if (installTargets.length > 0) { - await db - .insert(toolConnectionInstalls) - .values( - installTargets.map((target) => ({ - companyId: input.connection.companyId, - connectionId: input.connection.id, - ...target, - createdByAgentId: - input.actor?.actorType === "agent" - ? (input.actor.actorId ?? null) - : null, - createdByUserId: - input.actor?.actorType === "user" - ? (input.actor.actorId ?? null) - : null, - })), - ) - .onConflictDoNothing(); - } - } - return finished; - } - - async function completePaperclipCloudConnectorCallback(input: { - state: string; - claimId?: string | null; - error?: string | null; - actor?: ActorInfo; - }): Promise { - // Keep the local state live until the sealed claim is in the durable vault. - // The broker binds repeat claim requests to this stable state value, so a - // transient broker, database, or secret-store failure can retry safely. - const stateRow = await validateOAuthState(input.state, input.actor); - let connection = await getConnectionRow( - stateRow.connectionId, - stateRow.companyId, - ); - const preCloudCallbackLifecycle = { - status: connection.status, - enabled: connection.enabled, - }; - // The connection lifecycle, not the incidental presence of its app profile, - // distinguishes setup from reauthorization. New connections and connections - // revived after removal are drafts until this callback completes. A profile - // may legitimately survive removal because an MCP gateway retains it, or be - // intentionally archived on an otherwise active connection; neither case - // should invert whether recommended defaults are rebuilt. - const shouldFinalizeManagedDefaults = connection.status === "draft"; - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - const method = galleryEntry - ? connectionMethodForConnection(galleryEntry, connection) - : null; - const providerName = galleryEntry?.name ?? "Google Workspace"; - if (input.error) { - const consumedState = await consumeOAuthState(input.state, input.actor); - await rejectPendingOAuthInteraction(consumedState, input.actor); - throw new HttpError( - 400, - `${providerName} authorization did not complete. Start a new ${providerName} connection to try again.`, - { - code: - input.error === "access_denied" - ? "oauth_authorization_denied" - : "paperclip_cloud_connector_failed", - }, - ); - } - if (!input.claimId) - throw badRequest( - `${providerName} callback is missing a claim identifier`, - ); - const cloudConnector = currentCloudConnector(); - if (!cloudConnector) { - throw unprocessable( - `${providerName} connections through Paperclip are not available on this instance yet`, - { - code: "paperclip_cloud_connector_unavailable", - }, - ); - } - const subjectUserId = stateRow.subjectUserId; - const subjectAgentId = stateRow.subjectAgentId; - if ( - !method || - !isPaperclipCloudConnectorStrategy(method.oauthStrategy) || - (!subjectUserId && !subjectAgentId) - ) { - throw badRequest( - "OAuth state does not belong to a managed connector flow", - ); - } - const connectorProfile = method.connectorProfile; - const profile = managedConnectorProfile(connectorProfile); - if (!profile) throw badRequest("Managed connector profile is invalid"); - const connectorSubject = subjectAgentId - ? `agent:${subjectAgentId}` - : subjectUserId!; - const credentials = await cloudConnector.claim({ - subject: connectorSubject, - companyId: stateRow.companyId, - profile: profile.id, - claimId: input.claimId, - redemptionId: input.state, - ...(profile.id === "workspace.all" && stateRow.requestedScopes ? { scopes: stateRow.requestedScopes } : {}), - }); - if (profile.id === "workspace.all") { - const requested = normalizeOauthScopes(stateRow.requestedScopes); - credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); - if (!isGoogleWorkspaceScopeGrant(credentials.scopes)) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); - } - const refreshToken = credentials.refreshToken; - if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { - throw unprocessable( - `${providerName} did not return offline access. Reconnect and grant the requested permissions.`, - { - code: "oauth_refresh_missing", - }, - ); - } - const githubMetadata = - profile.provider === "github" - ? await loadGitHubGrantMetadata( - credentials.accessToken, - fetch, - credentials.appSlug, - ) - : null; - const authorizingUserId = - subjectUserId ?? - (stateRow.createdByActorType === "user" - ? stateRow.createdByActorId - : null); - if (!authorizingUserId) - throw forbidden( - `A signed-in connection manager must authorize ${providerName}`, - ); - - await db.transaction(async (tx) => { - // Keep connector credential persistence serialized with membership - // suspension, downgrade, and removal. A successful claim is only durable - // while the initiating user still holds connection-management authority. - const [membership] = await tx - .select({ - id: companyMemberships.id, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, authorizingUserId), - eq(companyMemberships.status, "active"), - ne(companyMemberships.membershipRole, "viewer"), - ), - ) - .limit(1) - .for("update"); - if (!membership) { - throw forbidden( - `Your company membership no longer permits connection changes. Restore non-viewer access before you connect ${providerName} again.`, - ); - } - if (subjectAgentId) { - const roleCanManage = - membership.membershipRole === "owner" || - membership.membershipRole === "admin"; - const [explicitManagerGrant] = roleCanManage - ? [] - : await tx - .select({ - id: principalPermissionGrants.id, - }) - .from(principalPermissionGrants) - .where( - and( - eq(principalPermissionGrants.companyId, connection.companyId), - eq(principalPermissionGrants.principalType, "user"), - eq(principalPermissionGrants.principalId, authorizingUserId), - eq( - principalPermissionGrants.permissionKey, - "tools:manage_connections", - ), - ), - ) - .limit(1) - .for("update"); - if (!roleCanManage && !explicitManagerGrant) { - throw forbidden( - "Only a connection manager can authorize a dedicated agent identity.", - ); - } - } - const [consumedState] = await tx - .delete(toolOauthStates) - .where( - and( - eq(toolOauthStates.state, input.state), - gte(toolOauthStates.expiresAt, new Date()), - ), - ) - .returning({ state: toolOauthStates.state }); - if (!consumedState) - throw badRequest( - "OAuth state was not found, expired, or has already been used", - ); - const txSecrets = secretService(tx); - const txSecretContext = { dbClient: tx, secretClient: txSecrets }; - const personalCredential = connection.credentialPolicy === "per_user"; - const agentCredential = connection.credentialPolicy === "per_agent"; - const grantKind: ConnectionGrantKind = agentCredential - ? "agent" - : personalCredential - ? "user" - : "organization"; - const [existingCredentialGrant] = await tx - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, grantKind), - agentCredential - ? eq(connectionGrants.subjectAgentId, subjectAgentId!) - : personalCredential - ? eq(connectionGrants.subjectUserId, subjectUserId!) - : eq(connectionGrants.isDefault, true), - ), - ) - .limit(1) - .for("update"); - await tx.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) - .for("update"); - await tx.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) - .for("update"); - const existingRefs = - existingCredentialGrant?.credentialSecretRefs ?? - (personalCredential || agentCredential - ? [] - : connection.credentialSecretRefs); - const accessRef = await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.access_token", - label: `${providerName} access token`, - value: credentials.accessToken, - actor: input.actor, - existingRefs, - ownerUserId: personalCredential ? subjectUserId! : undefined, - }, - txSecretContext, - ); - const refreshRef = refreshToken - ? await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.refresh_token", - label: `${providerName} refresh token`, - value: refreshToken, - actor: input.actor, - existingRefs, - ownerUserId: personalCredential ? subjectUserId! : undefined, - }, - txSecretContext, - ) - : null; - const credentialSecretRefs = [ - ...existingRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ), - accessRef, - ...(refreshRef ? [refreshRef] : []), - ]; - const grantValues = { - providerTenant: { - name: githubMetadata?.login ?? providerName, - externalId: credentials.subject, - oauth: { - strategy: "paperclip_cloud_connector", - accessTokenExpiresAt: credentials.accessTokenExpiresAt, - scopes: credentials.scopes, - requestedScopes: stateRow.requestedScopes ?? undefined, - tokenType: credentials.tokenType, - ...(credentials.refreshTokenExpiresAt - ? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt } - : {}), - ...(credentials.accessTokenExpiresAt - ? { refreshedAt: now().toISOString() } - : {}), - }, - ...(githubMetadata ? { github: githubMetadata } : {}), - }, - credentialSecretRefs, - status: "active" as const, - revokedAt: null, - revokedByAgentId: null, - revokedByUserId: null, - updatedAt: now(), - }; - if (existingCredentialGrant) { - await tx - .update(connectionGrants) - .set(grantValues) - .where(eq(connectionGrants.id, existingCredentialGrant.id)); - } else { - await tx.insert(connectionGrants).values({ - companyId: connection.companyId, - connectionId: connection.id, - kind: grantKind, - subjectUserId: personalCredential ? subjectUserId : null, - subjectAgentId: agentCredential ? subjectAgentId : null, - ...grantValues, - isDefault: grantKind === "organization", - createdByUserId: authorizingUserId, - }); - } - const nextConfig = { - ...connection.config, - oauth: { - ...oauthConfig(connection), - strategy: "paperclip_cloud_connector", - provider: galleryEntry?.slug, - connectorProfile, - connectorSubjectUserId: subjectUserId, - connectorSubjectAgentId: subjectAgentId, - resource: method.defaults?.serverUrl, - scopes: [...profile.scopes], - }, - }; - [connection] = await tx - .update(toolConnections) - .set({ - status: shouldFinalizeManagedDefaults ? "draft" : "active", - enabled: shouldFinalizeManagedDefaults ? false : true, - authKind: "oauth", - config: nextConfig, - transportConfig: nextConfig, - credentialRefs: - personalCredential || agentCredential - ? connection.credentialRefs.filter( - (ref) => ref.name !== "oauth.access_token", - ) - : [ - ...connection.credentialRefs.filter( - (ref) => ref.name !== "oauth.access_token", - ), - { - name: "oauth.access_token", - secretId: accessRef.secretId, - version: "latest" as const, - placement: "header" as const, - key: "Authorization", - prefix: "Bearer ", - }, - ], - credentialSecretRefs: - personalCredential || agentCredential - ? connection.credentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ) - : credentialSecretRefs, - updatedAt: now(), - }) - .where(eq(toolConnections.id, connection.id)) - .returning(); - await tx - .update(toolApplications) - .set({ - status: shouldFinalizeManagedDefaults ? "draft" : "active", - updatedAt: now(), - }) - .where(eq(toolApplications.id, connection.applicationId)); - await syncCredentialBindings( - connection, - personalCredential || agentCredential ? credentialSecretRefs : [], - tx, - ); - const linkedInteractionKind = stateRow.interactionId - ? await tx - .select({ kind: issueThreadInteractions.kind }) - .from(issueThreadInteractions) - .where(eq(issueThreadInteractions.id, stateRow.interactionId)) - .limit(1) - .then((rows) => rows[0]?.kind ?? null) - : null; - if ( - stateRow.interactionId && - linkedInteractionKind === "request_confirmation" - ) { - await tx - .update(issueThreadInteractions) - .set({ - status: "accepted", - result: { version: 1, outcome: "accepted" }, - resolvedByUserId: authorizingUserId, - resolvedAt: now(), - updatedAt: now(), - }) - .where(eq(issueThreadInteractions.id, stateRow.interactionId)); - } - }); - // The transaction above committed the connection's lifecycle write; a - // Paperclip Cloud connector callback is the managed variant of an OAuth - // callback completion. - emitConnectionUpdated(connection, preCloudCallbackLifecycle, "oauth_callback"); - if (githubMetadata) { - const [githubGrant] = await db - .select({ id: connectionGrants.id }) - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - subjectAgentId - ? and( - eq(connectionGrants.kind, "agent"), - eq(connectionGrants.subjectAgentId, subjectAgentId), - ) - : and( - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, subjectUserId!), - ), - ), - ) - .limit(1); - if (!githubGrant) throw new Error("GitHub grant was not persisted"); - await Promise.all( - githubMetadata.installationIds.map((installationId) => - cloudConnector.setWebhookBinding({ - subject: connectorSubject, - companyId: connection.companyId, - id: `${githubGrant.id}_${installationId}`, - installationId, - connectionId: connection.id, - grantId: githubGrant.id, - active: true, - accessToken: credentials.accessToken, - }), - ), - ); - } - const refresh = await refreshCatalog(connection.id, input.actor, { - enableAllByDefault: false, - quarantineManagedOAuthDraft: shouldFinalizeManagedDefaults, - skipDefaultProfileSync: true, - credentialHeaders: { Authorization: `Bearer ${credentials.accessToken}` }, - }); - const recommended = recommendedDefaultsForApp(galleryEntry!, method.key); - const suggestedDefaults = subjectAgentId - ? { ...recommended, access: { agentIds: [subjectAgentId] } as const } - : recommended; - const finished = shouldFinalizeManagedDefaults - ? await finishOAuthCatalogWithRecommendedDefaults({ - interactionId: stateRow.interactionId, - connection, - catalog: refresh.catalog, - suggestedDefaults, - activateQuarantined: true, - actor: input.actor, - }) - : null; - const activatedCatalogEntryIds = new Set( - shouldFinalizeManagedDefaults - ? refresh.catalog - .filter((entry) => entry.status === "quarantined") - .map((entry) => entry.id) - : [], - ); - const catalog = refresh.catalog.map((entry) => - activatedCatalogEntryIds.has(entry.id) - ? { ...entry, status: "active" as const } - : entry, - ); - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, connection.applicationId)); - return { - connectionId: connection.id, - application: toApplication(application), - connection: finished?.connection ?? refresh.connection, - catalog, - actions: groupedActions(catalog), - suggestedDefaults, - auth: null, - }; - } - - async function completeVercelConnectCallback(input: { - state: string; - error?: string | null; - actor?: ActorInfo; - }): Promise { - const stateRow = await consumeOAuthState(input.state, input.actor); - if (stateRow.codeVerifier !== "vercel-connect") { - throw badRequest("OAuth state does not belong to a Vercel Connect flow"); - } - if (input.error) { - await rejectPendingOAuthInteraction(stateRow, input.actor); - throw new HttpError( - 400, - "Vercel Connect authorization did not complete. Start a new authorization to try again.", - { - code: - input.error === "access_denied" - ? "oauth_authorization_denied" - : "vercel_connect_authorization_required", - }, - ); - } - if (!vercelConnect) - throw vercelConnectHttpError( - new VercelConnectClientError("vercel_connect_unavailable", 503), - ); - let connection = await getConnectionRow( - stateRow.connectionId, - stateRow.companyId, - ); - const credential = vercelCredentialFor(connection); - if ( - credential.principalMode !== "user" || - connection.authKind !== "oauth" - ) { - throw badRequest( - "Vercel Connect callback does not match this connection", - ); - } - const grantKind: ConnectionGrantKind = stateRow.subjectUserId - ? "user" - : "organization"; - if (stateRow.subjectUserId) { - const [membership] = await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, stateRow.subjectUserId), - eq(companyMemberships.status, "active"), - ne(companyMemberships.membershipRole, "viewer"), - ), - ) - .limit(1); - if (!membership) { - throw forbidden( - "Your company membership no longer permits connection changes. Restore non-viewer access before authorizing this connection.", - ); - } - } - const [existingGrant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, grantKind), - grantKind === "user" - ? eq(connectionGrants.subjectUserId, stateRow.subjectUserId!) - : eq(connectionGrants.isDefault, true), - ), - ) - .limit(1); - const derived = deriveVercelConnectSubject({ - credential, - connectionId: connection.id, - companyId: connection.companyId, - grantKind, - subjectUserId: stateRow.subjectUserId, - }); - const request = vercelTokenRequest({ - credential, - connectionId: connection.id, - companyId: connection.companyId, - resources: vercelConnectResourcesFor(connection), - grant: { - kind: grantKind, - subjectUserId: stateRow.subjectUserId, - externalCredential: existingGrant?.externalCredential, - }, - }); - let token; - try { - token = await vercelConnect.getToken(request, { forceRefresh: true }); - } catch (error) { - throw vercelConnectHttpError(error); - } - if ( - token.connector.id !== credential.connectorId && - token.connector.uid !== credential.connectorUid - ) { - throw new HttpError( - 502, - "Vercel Connect returned a token for a different connector.", - { - code: "vercel_connect_connector_mismatch", - }, - ); - } - const externalGrant = vercelGrantReference({ - credential, - token, - subjectId: derived.subjectId, - verifiedAt: now(), - }); - const grantValues = { - providerTenant: token.tenantId - ? { name: credential.service, externalId: token.tenantId } - : { name: credential.service }, - credentialSecretRefs: [], - externalCredential: externalGrant, - status: "active" as const, - isDefault: grantKind === "organization", - revokedAt: null, - revokedByAgentId: null, - revokedByUserId: null, - updatedAt: now(), - }; - if (existingGrant) { - await db - .update(connectionGrants) - .set(grantValues) - .where(eq(connectionGrants.id, existingGrant.id)); - } else { - await db.insert(connectionGrants).values({ - companyId: connection.companyId, - connectionId: connection.id, - kind: grantKind, - subjectUserId: stateRow.subjectUserId, - ...grantValues, - createdByUserId: - input.actor?.actorType === "user" - ? (input.actor.actorId ?? null) - : null, - }); - } - const preActivationLifecycle = { - status: connection.status, - enabled: connection.enabled, - }; - [connection] = await db - .update(toolConnections) - .set({ - status: "active", - enabled: true, - credentialRefs: [], - credentialSecretRefs: [], - updatedAt: now(), - }) - .where( - and( - eq(toolConnections.id, connection.id), - eq(toolConnections.companyId, connection.companyId), - ), - ) - .returning(); - emitConnectionUpdated( - connection, - preActivationLifecycle, - "oauth_callback", - ); - await db - .update(toolApplications) - .set({ status: "active", updatedAt: now() }) - .where(eq(toolApplications.id, connection.applicationId)); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - if (!galleryEntry) - throw badRequest( - "Vercel Connect connection is missing its reviewed app definition", - ); - const method = connectionMethodForConnection(galleryEntry, connection); - const refresh = await refreshCatalog(connection.id, input.actor, { - enableAllByDefault: true, - skipDefaultProfileSync: true, - credentialHeaders: { - ...projectedConnectionHeaders(connection), - [credential.headerName]: `${credential.headerPrefix ?? ""}${token.token}`, - }, - }); - const suggestedDefaults = recommendedDefaultsForApp( - galleryEntry, - method.key, - ); - const finished = await finishOAuthCatalogWithRecommendedDefaults({ - interactionId: stateRow.interactionId, - connection, - catalog: refresh.catalog, - suggestedDefaults, - actor: input.actor, - }); - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, connection.applicationId)); - return { - connectionId: connection.id, - application: toApplication(application), - connection: finished.connection, - catalog: refresh.catalog, - actions: groupedActions(refresh.catalog), - suggestedDefaults, - auth: null, - }; - } - - async function completeOAuthCallback(input: { - state: string; - code?: string | null; - /** - * RFC 6749 `error`. Untrusted, and deliberately the *only* thing read from a - * failed callback — `error_description` and `error_uri` are not accepted as - * input at all, so there is nothing for a hostile provider to reflect - * through (PAP-17108). - */ - error?: string | null; - redirectUri: string; - /** RFC 9207 `iss`, when the authorization server returns it. */ - iss?: string | null; - actor?: ActorInfo; - }): Promise { - // Binding first, outcome second: the provider's report of a failure is only - // acted on once the callback is bound to a state Paperclip issued and to the - // actor that started the flow, so an unsolicited callback cannot drive any - // path here. Consuming the state up front is what makes a denial terminal — - // a refused request must not stay completable by a later code (PAP-17109). - const stateRow = await consumeOAuthState(input.state, input.actor); - if (input.error) { - await rejectPendingOAuthInteraction(stateRow, input.actor); - const providerError = normalizeOAuthProviderError(input.error); - throw new HttpError( - 400, - oauthProviderErrorMessage( - providerError, - "The authorization server denied the request.", - ), - { - code: "oauth_authorization_denied", - providerError, - }, - ); - } - // Neither a code nor an error is not a usable answer either. It still spends - // the request: the recovery is a fresh authorization, not a state left live - // waiting for a better callback. - if (!input.code) throw badRequest("OAuth callback is missing a code"); - - let connection = await getConnectionRow( - stateRow.connectionId, - stateRow.companyId, - ); - // Reauthorization refreshes credentials and catalog without rebuilding the - // operator's action profile, policy rules, or access bindings. - const shouldFinalizeDefaults = connection.status === "draft"; - const preCallbackLifecycle = { - status: connection.status, - enabled: connection.enabled, - }; - await assertConnectorConnectionSetupEnabled(connection); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - assertOAuthRedirectConstraints(galleryEntry, input.redirectUri); - const endpoints = await oauthEndpointsForConnection( - connection, - null, - input.redirectUri, - ); - assertOAuthCallbackIssuer(connection, endpoints, input.iss); - // Generic states created before requested scopes were persisted may still - // complete after an upgrade. Keep their original cached/discovered scope set. - const authorizedScopes = normalizeOauthScopes( - stateRow.requestedScopes ?? (galleryEntry ? [] : endpoints.scopes), - ); - const client = await oauthClientForConnection( - connection, - endpoints.provider, - input.actor, - ); - if (!client.clientId) - throw unprocessable( - `OAuth client id is not configured for ${endpoints.provider}`, - ); - - const token = await exchangeOAuthToken({ - tokenUrl: endpoints.tokenUrl, - clientId: client.clientId, - clientSecret: client.clientSecret, - tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod( - oauthConfig(connection), - client.clientSecret, - ), - redirectUri: input.redirectUri, - codeVerifier: stateRow.codeVerifier, - code: input.code, - resource: endpoints.resource, - }); - if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { - throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" }); - } - if (sourceTemplateKey === "google-workspace") { - token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" "); - if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); - } - const connectedAt = now(); - const expiresAt = token.expiresIn - ? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString() - : null; - if (stateRow.subjectUserId) { - let personalCredentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs = - []; - await db.transaction(async (tx) => { - // Serialize callback persistence with suspension/removal. Those paths - // lock this same membership row before sweeping personal credentials. - const [membership] = await tx - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, stateRow.subjectUserId!), - eq(companyMemberships.status, "active"), - ne(companyMemberships.membershipRole, "viewer"), - ), - ) - .limit(1) - .for("update"); - if (!membership) { - throw forbidden( - "Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.", - ); - } - const txSecrets = secretService(tx); - const txSecretContext = { dbClient: tx, secretClient: txSecrets }; - - const [existingUserGrant] = await tx - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, stateRow.subjectUserId!), - ), - ) - .limit(1) - .for("update"); - await tx.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) - .for("update"); - await tx.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) - .for("update"); - const subjectCredentialSecretRefs = - existingUserGrant?.credentialSecretRefs ?? []; - const accessRef = await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.access_token", - label: "OAuth access token", - value: token.accessToken, - actor: input.actor, - existingRefs: subjectCredentialSecretRefs, - ownerUserId: stateRow.subjectUserId!, - }, - txSecretContext, - ); - const nextCredentialSecretRefs = [ - ...subjectCredentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ), - accessRef, - ]; - if (token.refreshToken) { - nextCredentialSecretRefs.push( - await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.refresh_token", - label: "OAuth refresh token", - value: token.refreshToken, - actor: input.actor, - existingRefs: subjectCredentialSecretRefs, - ownerUserId: stateRow.subjectUserId!, - }, - txSecretContext, - ), - ); - } else { - const existingRefreshRef = subjectCredentialSecretRefs.find( - (ref) => ref.configPath === "oauth.refresh_token", - ); - if (existingRefreshRef) - nextCredentialSecretRefs.push(existingRefreshRef); - } - - const grantedScopes = resolveGrantedOauthScopes({ - tokenScope: token.scope, - requestedScopes: authorizedScopes, - }); - const grantValues = { - providerTenant: { - ...(existingUserGrant?.providerTenant ?? {}), - oauth: { - ...asRecord(asRecord(existingUserGrant?.providerTenant).oauth), - strategy: "direct_oauth", - accessTokenExpiresAt: expiresAt ?? undefined, - scopes: grantedScopes.scopes, - scopeSource: grantedScopes.scopeSource, - unrequestedScopes: grantedScopes.unrequestedScopes, - // Per-grant, because two users can authorize the same connection with - // different scopes. The connection-level list is whichever callback ran last, - // so it is the wrong baseline for anyone else's refresh. - requestedScopes: authorizedScopes, - tokenType: token.tokenType, - refreshedAt: connectedAt.toISOString(), - }, - }, - credentialSecretRefs: nextCredentialSecretRefs, - status: "active" as const, - revokedAt: null, - revokedByAgentId: null, - revokedByUserId: null, - updatedAt: new Date(), - }; - if (existingUserGrant) { - await tx - .update(connectionGrants) - .set(grantValues) - .where(eq(connectionGrants.id, existingUserGrant.id)); - } else { - await tx.insert(connectionGrants).values({ - companyId: connection.companyId, - connectionId: connection.id, - kind: "user", - subjectUserId: stateRow.subjectUserId!, - ...grantValues, - isDefault: false, - createdByUserId: stateRow.subjectUserId!, - }); - } - personalCredentialSecretRefs = nextCredentialSecretRefs; - const nextConfig = { - ...connection.config, - oauth: { - ...withoutOAuthRefreshLease(oauthConfig(connection)), - provider: endpoints.provider, - authorizationUrl: endpoints.authorizationUrl, - tokenUrl: endpoints.tokenUrl, - metadataUrl: endpoints.metadataUrl ?? null, - scopes: authorizedScopes, - clientIdEnv: client.clientIdEnv, - clientSecretEnv: client.clientSecret - ? client.clientSecretEnv - : null, - credentialScope: credentialScope(connection, input.actor), - issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, - resource: - endpoints.resource ?? oauthConfig(connection).resource ?? null, - expiresAt, - scope: token.scope, - tokenType: token.tokenType, - connectedAt: connectedAt.toISOString(), - }, - providerMetadata: { - ...asRecord(connection.config.providerMetadata), - oauth: { - expiresAt, - scope: token.scope, - scopeSource: grantedScopes.scopeSource, - unrequestedScopes: grantedScopes.unrequestedScopes, - tokenType: token.tokenType, - }, - }, - }; - const [updatedConnection] = await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - authKind: "oauth", - credentialPolicy: connection.credentialPolicy, - config: nextConfig, - transportConfig: nextConfig, - // A personal-only connection keeps tokens exclusively on its user - // grant. Adding a personal identity to an existing shared/fallback - // connection must not erase that connection's organization token. - credentialRefs: - connection.credentialPolicy === "per_user" - ? connection.credentialRefs.filter( - (ref) => ref.name !== "oauth.access_token", - ) - : connection.credentialRefs, - credentialSecretRefs: - connection.credentialPolicy === "per_user" - ? connection.credentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ) - : connection.credentialSecretRefs, - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, connection.id)) - .returning(); - if (!updatedConnection) - throw new Error("OAuth connection was not found"); - connection = updatedConnection; - await tx - .update(toolApplications) - .set({ - status: "active", - updatedAt: new Date(), - }) - .where(eq(toolApplications.id, connection.applicationId)); - const linkedInteractionKind = stateRow.interactionId - ? await tx - .select({ kind: issueThreadInteractions.kind }) - .from(issueThreadInteractions) - .where(eq(issueThreadInteractions.id, stateRow.interactionId)) - .limit(1) - .then((rows) => rows[0]?.kind ?? null) - : null; - if ( - stateRow.interactionId && - linkedInteractionKind === "request_confirmation" - ) { - await tx - .update(issueThreadInteractions) - .set({ - status: "accepted", - result: { version: 1, outcome: "accepted" }, - resolvedByUserId: stateRow.subjectUserId!, - resolvedAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - eq(issueThreadInteractions.id, stateRow.interactionId), - eq(issueThreadInteractions.companyId, connection.companyId), - ), - ); - } - await syncCredentialBindings( - connection, - connection.credentialPolicy === "per_user" - ? personalCredentialSecretRefs - : [], - tx, - ); - }); - emitConnectionUpdated( - connection, - preCallbackLifecycle, - "oauth_callback", - ); - - // Personal OAuth used to return immediately after saving the grant. That - // left the connection draft/paused and its catalog empty, so the person - // who had just consented landed on a false "Nothing to test" state. - // Activate and discover with the just-issued token before returning. - const refresh = await refreshCatalog(connection.id, input.actor, { - enableAllByDefault: shouldFinalizeDefaults, - skipDefaultProfileSync: shouldFinalizeDefaults, - credentialHeaders: { Authorization: `Bearer ${token.accessToken}` }, - }); - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, connection.applicationId)); - if (!application) - throw new Error("OAuth connection application was not found"); - const suggestedDefaults = galleryEntry - ? recommendedDefaultsForApp( - galleryEntry, - connectionMethodForConnection(galleryEntry, connection).key, - ) - : { access: "all_agents" as const, askFirstRiskLevels: [] }; - const finished = shouldFinalizeDefaults - ? await finishOAuthCatalogWithRecommendedDefaults({ - interactionId: stateRow.interactionId, - connection, - catalog: refresh.catalog, - suggestedDefaults, - actor: input.actor, - }) - : null; - return { - connectionId: refresh.connection.id, - application: toApplication(application), - connection: finished?.connection ?? refresh.connection, - catalog: refresh.catalog, - actions: groupedActions(refresh.catalog), - suggestedDefaults, - auth: null, - }; - } - - const organizationActorUserId = - stateRow.createdByActorType === "user" ? stateRow.createdByActorId : null; - if (!organizationActorUserId) { - throw forbidden( - "Organization OAuth completion requires the user who started sign-in", - ); - } - await db.transaction(async (tx) => { - // Keep callback persistence serialized with membership suspension, role - // downgrade, and removal. Once this row is locked, authority cannot be - // revoked between the live check and the shared credential/grant writes. - const [membership] = await tx - .select({ - id: companyMemberships.id, - membershipRole: companyMemberships.membershipRole, - }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, connection.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, organizationActorUserId), - eq(companyMemberships.status, "active"), - ne(companyMemberships.membershipRole, "viewer"), - ), - ) - .limit(1) - .for("update"); - if (!membership) { - throw forbidden( - "Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.", - ); - } - const roleCanManage = - membership.membershipRole === "owner" || - membership.membershipRole === "admin"; - const [explicitManagerGrant] = roleCanManage - ? [] - : await tx - .select({ - id: principalPermissionGrants.id, - }) - .from(principalPermissionGrants) - .where( - and( - eq(principalPermissionGrants.companyId, connection.companyId), - eq(principalPermissionGrants.principalType, "user"), - eq( - principalPermissionGrants.principalId, - organizationActorUserId, - ), - eq( - principalPermissionGrants.permissionKey, - "tools:manage_connections", - ), - ), - ) - .limit(1) - .for("update"); - if (!roleCanManage && !explicitManagerGrant) { - throw forbidden( - "Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.", - ); - } - await tx.select({ id: connectionGrants.id }).from(connectionGrants) - .where(and(eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true))) - .for("update"); - await tx.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) - .for("update"); - await tx.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) - .for("update"); - const txSecrets = secretService(tx); - const txSecretContext = { dbClient: tx, secretClient: txSecrets }; - - const subjectCredentialSecretRefs = connection.credentialSecretRefs; - const accessRef = await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.access_token", - label: "OAuth access token", - value: token.accessToken, - actor: input.actor, - }, - txSecretContext, - ); - const nextCredentialSecretRefs = [ - ...subjectCredentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ), - accessRef, - ]; - if (token.refreshToken) { - nextCredentialSecretRefs.push( - await createOrRotateOAuthSecret( - { - companyId: connection.companyId, - connection, - configPath: "oauth.refresh_token", - label: "OAuth refresh token", - value: token.refreshToken, - actor: input.actor, - }, - txSecretContext, - ), - ); - } else { - const existingRefreshRef = subjectCredentialSecretRefs.find( - (ref) => ref.configPath === "oauth.refresh_token", - ); - if (existingRefreshRef) - nextCredentialSecretRefs.push(existingRefreshRef); - } - const organizationGrantedScopes = resolveGrantedOauthScopes({ - tokenScope: token.scope, - requestedScopes: authorizedScopes, - }); - const nextConfig = { - ...connection.config, - oauth: { - ...withoutOAuthRefreshLease(oauthConfig(connection)), - provider: endpoints.provider, - authorizationUrl: endpoints.authorizationUrl, - tokenUrl: endpoints.tokenUrl, - metadataUrl: endpoints.metadataUrl ?? null, - scopes: authorizedScopes, - clientIdEnv: client.clientIdEnv, - clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null, - credentialScope: credentialScope(connection, input.actor), - // Keep the issuer and resource this grant was minted against so refresh, - // reconnect, revoke and diagnostics resolve the same authorization server - // instead of re-discovering one from a possibly-changed endpoint. - issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, - resource: - endpoints.resource ?? oauthConfig(connection).resource ?? null, - expiresAt, - scope: token.scope, - tokenType: token.tokenType, - connectedAt: connectedAt.toISOString(), - }, - providerMetadata: { - ...asRecord(connection.config.providerMetadata), - oauth: { - expiresAt, - scope: token.scope, - scopeSource: organizationGrantedScopes.scopeSource, - unrequestedScopes: organizationGrantedScopes.unrequestedScopes, - tokenType: token.tokenType, - }, - }, - }; - const [updatedConnection] = await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - authKind: "oauth", - config: nextConfig, - transportConfig: nextConfig, - credentialSecretRefs: nextCredentialSecretRefs, - credentialRefs: [ - ...connection.credentialRefs.filter( - (ref) => ref.name !== "oauth.access_token", - ), - { - name: "oauth.access_token", - secretId: accessRef.secretId, - version: "latest" as const, - placement: "header" as const, - key: "Authorization", - prefix: "Bearer ", - }, - ], - updatedAt: new Date(), - }) - .where(eq(toolConnections.id, connection.id)) - .returning(); - connection = updatedConnection; - await tx - .update(toolApplications) - .set({ status: "active", updatedAt: new Date() }) - .where(eq(toolApplications.id, connection.applicationId)); - // The organization grant is created before OAuth has any secrets to attach. - // Synchronize it after every successful callback/rotation so all real tool - // execution paths receive the credentials that setup and catalog discovery - // just proved. - await ensureDefaultOrganizationGrant(connection, tx, undefined, { - scopes: organizationGrantedScopes.scopes, - scopeSource: organizationGrantedScopes.scopeSource, - unrequestedScopes: organizationGrantedScopes.unrequestedScopes, - requestedScopes: authorizedScopes, - }); - await syncCredentialBindings(connection, [], tx); - }); - emitConnectionUpdated( - connection, - preCallbackLifecycle, - "oauth_callback", - ); - - await checkConnectionHealth(connection.id, input.actor); - const refresh = await refreshCatalog(connection.id, input.actor, { - enableAllByDefault: shouldFinalizeDefaults, - skipDefaultProfileSync: shouldFinalizeDefaults, - }); - const [application] = await db - .select() - .from(toolApplications) - .where(eq(toolApplications.id, connection.applicationId)); - const suggestedDefaults = galleryEntry - ? recommendedDefaultsForApp( - galleryEntry, - connectionMethodForConnection(galleryEntry, connection).key, - ) - : { - access: "all_agents" as const, - askFirstRiskLevels: [], - }; - const finished = shouldFinalizeDefaults - ? await finishOAuthCatalogWithRecommendedDefaults({ - interactionId: stateRow.interactionId, - connection, - catalog: refresh.catalog, - suggestedDefaults, - actor: input.actor, - }) - : null; - return { - connectionId: refresh.connection.id, - application: toApplication(application), - connection: finished?.connection ?? refresh.connection, - catalog: refresh.catalog, - actions: groupedActions(refresh.catalog), - suggestedDefaults, - auth: null, - }; - } - - /** - * Finish the one decision that cannot safely be guessed for a browser OAuth - * connection: whether the consenting identity stays personal or becomes the - * company's shared identity. The provider callback always writes a fresh - * token to the consenting user's grant first. Only this explicit endpoint may - * promote it to company-scoped secrets. - */ - async function finalizeOAuthAccess( - companyId: string, - connectionId: string, - input: FinalizeOAuthAccess, - actor?: ActorInfo, - requestingAgentId?: string, - ): Promise { - if (requestingAgentId) - await assertAgentsInCompany(companyId, [requestingAgentId]); - let connection = await getConnectionRow(connectionId, companyId); - const preFinalizeLifecycle = { - status: connection.status, - enabled: connection.enabled, - }; - if (connection.authKind !== "oauth") - throw badRequest("This connection does not use browser sign-in"); - if (connection.status === "archived") - throw conflict("Archived app connections cannot be finished"); - const actorUserId = actor?.actorType === "user" ? actor.actorId : null; - if (!actorUserId) - throw badRequest("Finishing browser sign-in requires a signed-in user"); - - const [personalGrant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, actorUserId), - ), - ) - .limit(1); - - if (connection.credentialSource === "vercel_connect") { - const [organizationGrant] = await db - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "organization"), - eq(connectionGrants.isDefault, true), - ), - ) - .limit(1); - const selectedGrant = - input.grantKind === "user" ? personalGrant : organizationGrant; - if ( - !selectedGrant || - selectedGrant.status !== "active" || - !selectedGrant.externalCredential - ) { - throw conflict( - "The selected Vercel Connect identity is missing. Authorize this connection again.", - ); - } - const expectedPolicy = input.grantKind === "user" ? "per_user" : "shared"; - if (connection.credentialPolicy !== expectedPolicy) { - throw conflict( - "Vercel Connect identity scope is fixed when the connector is attached. Create a new connection to change it.", - ); - } - } else if (input.grantKind === "user") { - if ( - !personalGrant || - personalGrant.status !== "active" || - personalGrant.credentialSecretRefs.length === 0 - ) { - throw conflict( - "Your connected identity is missing. Connect this app again before choosing Just me.", - ); - } - if ( - connection.credentialPolicy === "shared" && - connection.credentialSecretRefs.length > 0 - ) { - throw conflict("This connection already uses a company identity"); - } - [connection] = await db - .update(toolConnections) - .set({ - credentialPolicy: "per_user", - credentialRefs: connection.credentialRefs.filter( - (ref) => ref.name !== "oauth.access_token", - ), - credentialSecretRefs: connection.credentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ), - status: "active", - enabled: true, - updatedAt: new Date(), - }) - .where( - and( - eq(toolConnections.id, connection.id), - eq(toolConnections.companyId, companyId), - ), - ) - .returning(); - await syncCredentialBindings( - connection, - personalGrant.credentialSecretRefs, - ); - } else if ( - connection.credentialPolicy !== "shared" || - connection.credentialSecretRefs.length === 0 - ) { - if ( - !personalGrant || - personalGrant.status !== "active" || - personalGrant.credentialSecretRefs.length === 0 - ) { - throw conflict( - "Your connected identity is missing. Connect this app again before sharing it.", - ); - } - - const personalSecretIds = personalGrant.credentialSecretRefs.map( - (ref) => ref.secretId, - ); - const personalSecretRows = await db - .select({ - id: companySecrets.id, - scope: companySecrets.scope, - ownerUserId: companySecrets.ownerUserId, - userSecretDefinitionId: companySecrets.userSecretDefinitionId, - }) - .from(companySecrets) - .where( - and( - eq(companySecrets.companyId, companyId), - inArray(companySecrets.id, personalSecretIds), - ), - ); - const personalSecretById = new Map( - personalSecretRows.map((row) => [row.id, row]), - ); - const promotedRefs: ToolCredentialSecretRef[] = []; - try { - for (const ref of personalGrant.credentialSecretRefs) { - const secretRow = personalSecretById.get(ref.secretId); - if ( - !secretRow || - secretRow.scope !== "user" || - secretRow.ownerUserId !== actorUserId || - !secretRow.userSecretDefinitionId - ) { - throw forbidden( - "Only your own connected identity can be shared with the company", - ); - } - if ( - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token" && - ref.configPath !== "oauth.client_secret" - ) { - throw badRequest( - "The connected identity contains an unsupported OAuth credential", - ); - } - const resolved = await secrets.resolveUserSecretValue( - companyId, - { - definitionId: secretRow.userSecretDefinitionId, - responsibleUserId: actorUserId, - version: ref.versionSelector ?? "latest", - }, - { - consumerType: "tool_connection", - consumerId: connection.id, - responsibleUserId: actorUserId, - actorType: "user", - actorId: actorUserId, - }, - ); - if (!resolved) - throw unprocessable("The connected identity could not be read"); - const promoted = await createOrRotateOAuthSecret({ - companyId, - connection, - configPath: ref.configPath, - label: ref.label ?? "OAuth credential", - value: resolved.value, - actor, - existingRefs: promotedRefs, - }); - promotedRefs.push({ ...ref, ...promoted }); - } - - const accessRef = promotedRefs.find( - (ref) => ref.configPath === "oauth.access_token", - ); - if (!accessRef) - throw unprocessable( - "The connected identity is missing its OAuth access token", - ); - const connectionCredentialSecretRefs = [ - ...connection.credentialSecretRefs.filter( - (ref) => - ref.configPath !== "oauth.access_token" && - ref.configPath !== "oauth.refresh_token", - ), - ...promotedRefs, - ]; - const nowAt = new Date(); - await db.transaction(async (tx) => { - await tx.select({ id: connectionGrants.id }).from(connectionGrants) - .where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connection.id), - or(eq(connectionGrants.id, personalGrant.id), and(eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true))))) - .orderBy(connectionGrants.id).for("update"); - await tx.select({ id: toolApplications.id }).from(toolApplications) - .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) - .for("update"); - await tx.select({ id: toolConnections.id }).from(toolConnections) - .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))) - .for("update"); - const [existingOrganizationGrant] = await tx - .select() - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "organization"), - eq(connectionGrants.isDefault, true), - ), - ) - .limit(1); - let organizationGrantId: string; - if (existingOrganizationGrant) { - organizationGrantId = existingOrganizationGrant.id; - await tx - .update(connectionGrants) - .set({ - providerTenant: personalGrant.providerTenant, - credentialSecretRefs: connectionCredentialSecretRefs, - status: "active", - revokedAt: null, - revokedByAgentId: null, - revokedByUserId: null, - updatedAt: nowAt, - }) - .where(eq(connectionGrants.id, existingOrganizationGrant.id)); - } else { - const [createdOrganizationGrant] = await tx - .insert(connectionGrants) - .values({ - companyId, - connectionId: connection.id, - kind: "organization", - subjectUserId: null, - providerTenant: personalGrant.providerTenant, - credentialSecretRefs: connectionCredentialSecretRefs, - status: "active", - isDefault: true, - createdByUserId: actorUserId, - }) - .returning({ id: connectionGrants.id }); - organizationGrantId = createdOrganizationGrant.id; - } - // Empty audience rows are the canonical "everyone in the company". - await tx - .delete(connectionGrantMembers) - .where( - and( - eq(connectionGrantMembers.companyId, companyId), - eq(connectionGrantMembers.grantId, organizationGrantId), - ), - ); - await tx - .delete(connectionGrantDelegations) - .where( - and( - eq(connectionGrantDelegations.companyId, companyId), - eq(connectionGrantDelegations.grantId, personalGrant.id), - ), - ); - await tx - .update(connectionGrants) - .set({ - credentialSecretRefs: [], - status: "revoked", - revokedAt: nowAt, - revokedByUserId: actorUserId, - updatedAt: nowAt, - }) - .where(eq(connectionGrants.id, personalGrant.id)); - [connection] = await tx - .update(toolConnections) - .set({ - credentialPolicy: "shared", - credentialSecretRefs: connectionCredentialSecretRefs, - credentialRefs: [ - { - name: "oauth.access_token", - secretId: accessRef.secretId, - version: "latest", - placement: "header", - key: "Authorization", - prefix: "Bearer ", - }, - ], - status: "active", - enabled: true, - updatedAt: nowAt, - }) - .where( - and( - eq(toolConnections.id, connection.id), - eq(toolConnections.companyId, companyId), - ), - ) - .returning(); - }); - } catch (error) { - await Promise.all( - promotedRefs.map((ref) => - secrets.remove(ref.secretId).catch(() => undefined), - ), - ); - throw error; - } - await syncCredentialBindings(connection); - // The source values are no longer referenced by either grant or - // connection. Removing them makes the promotion a move, not a copy. - for (const secretId of personalSecretIds) await secrets.remove(secretId); - } - - // Both identity branches above commit their own lifecycle write (draft -> - // active) before `finishGalleryAppConnection` re-reads the row, so that - // step alone would never observe this transition. This is the OAuth - // access-finalization step of the same gallery setup flow (Apps and inline - // task cards both reach it), hence `gallery`. - emitConnectionUpdated(connection, preFinalizeLifecycle, "gallery"); - const catalog = await db - .select() - .from(toolCatalogEntries) - .where( - and( - eq(toolCatalogEntries.companyId, companyId), - eq(toolCatalogEntries.connectionId, connection.id), - eq(toolCatalogEntries.status, "active"), - ), - ); - const sourceTemplateKey = - typeof connection.config.sourceTemplateKey === "string" - ? connection.config.sourceTemplateKey - : null; - const galleryEntry = sourceTemplateKey - ? getConnectableAppDefinition(sourceTemplateKey) - : null; - const defaults = galleryEntry - ? recommendedDefaultsForApp( - galleryEntry, - connectionMethodForConnection(galleryEntry, connection).key, - ) - : { askFirstRiskLevels: [] }; - const askFirstRiskLevels = new Set( - Array.isArray(defaults.askFirstRiskLevels) - ? defaults.askFirstRiskLevels.filter( - (value): value is string => typeof value === "string", - ) - : [], - ); - const finished = await finishGalleryAppConnection( - companyId, - connection.id, - { - enabledCatalogEntryIds: catalog.map((entry) => entry.id), - askFirstCatalogEntryIds: catalog - .filter((entry) => askFirstRiskLevels.has(entry.riskLevel)) - .map((entry) => entry.id), - access: requestingAgentId - ? { agentIds: [requestingAgentId] } - : "all_agents", - preserveExistingAccess: Boolean(requestingAgentId), - }, - actor, - ); - await db - .insert(toolConnectionInstalls) - .values({ - companyId, - connectionId: connection.id, - targetType: requestingAgentId ? "agent" : "company", - targetId: requestingAgentId ?? companyId, - createdByUserId: actorUserId, - }) - .onConflictDoNothing(); - return finished; - } - async function preflightGalleryAppMetadata( galleryKey: string, methodKey?: string | null, diff --git a/server/src/services/tool-access/actor.ts b/server/src/services/tool-access/actor.ts new file mode 100644 index 00000000000..99147aaaaa1 --- /dev/null +++ b/server/src/services/tool-access/actor.ts @@ -0,0 +1,12 @@ +import type { ActorInfo } from "./types.js"; + +export function actorBinding(actor: ActorInfo | undefined) { + return { + actorType: actor?.actorType ?? null, + actorId: actor?.actorId ?? null, + sessionId: + typeof actor?.sessionId === "string" && actor.sessionId.trim().length > 0 + ? actor.sessionId + : null, + }; +} diff --git a/server/src/services/tool-access/applications/projection.ts b/server/src/services/tool-access/applications/projection.ts new file mode 100644 index 00000000000..b41a8525164 --- /dev/null +++ b/server/src/services/tool-access/applications/projection.ts @@ -0,0 +1,23 @@ +import { toolApplications } from "@paperclipai/db"; +import type { ToolApplication } from "@paperclipai/shared"; + +export function toApplication( + row: typeof toolApplications.$inferSelect, +): ToolApplication { + return { + id: row.id, + companyId: row.companyId, + applicationKey: row.applicationKey ?? undefined, + name: row.name, + description: row.description, + type: row.type, + status: row.status, + pluginId: row.pluginId, + ownerAgentId: row.ownerAgentId, + ownerUserId: row.ownerUserId, + metadata: row.metadata ?? null, + archivedAt: row.archivedAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} diff --git a/server/src/services/tool-access/catalog/action-summary.ts b/server/src/services/tool-access/catalog/action-summary.ts new file mode 100644 index 00000000000..7dfbf76af9e --- /dev/null +++ b/server/src/services/tool-access/catalog/action-summary.ts @@ -0,0 +1,38 @@ +import type { ConnectToolAppResult, ToolCatalogEntry, ToolAppConnectionActionSummary } from "@paperclipai/shared"; + +export function actionSummary( + entry: ToolCatalogEntry, +): ToolAppConnectionActionSummary { + return { + catalogEntryId: entry.id, + toolName: entry.toolName, + title: entry.title, + description: entry.description, + riskLevel: entry.riskLevel, + isReadOnly: entry.isReadOnly, + isWrite: entry.isWrite, + isDestructive: entry.isDestructive, + status: entry.status, + }; +} + +export function groupedActions( + catalog: ToolCatalogEntry[], +): ConnectToolAppResult["actions"] { + const readOnly: ToolAppConnectionActionSummary[] = []; + const canMakeChanges: ToolAppConnectionActionSummary[] = []; + for (const entry of catalog) { + const summary = actionSummary(entry); + if ( + entry.isReadOnly && + entry.riskLevel === "read" && + !entry.isWrite && + !entry.isDestructive + ) { + readOnly.push(summary); + } else { + canMakeChanges.push(summary); + } + } + return { readOnly, canMakeChanges }; +} diff --git a/server/src/services/tool-access/catalog/contracts.ts b/server/src/services/tool-access/catalog/contracts.ts new file mode 100644 index 00000000000..6cfa17e822a --- /dev/null +++ b/server/src/services/tool-access/catalog/contracts.ts @@ -0,0 +1,13 @@ +import type { ActorInfo } from "../types.js"; +import type { FinishToolApp, FinishToolAppResult, ToolCatalogRefreshResult, ToolConnectionHealthCheckResult } from "@paperclipai/shared"; + +/** Capabilities used by setup and authorization before catalog ownership is constructed. */ +export type RefreshConnectionCatalog = (connectionId: string, actor?: ActorInfo, options?: { + enableAllByDefault?: boolean; + restoreDraftDefaults?: boolean; + quarantineManagedOAuthDraft?: boolean; + skipDefaultProfileSync?: boolean; + credentialHeaders?: Record; +}) => Promise; +export type CheckConnectionHealth = (connectionId: string, actor?: ActorInfo) => Promise; +export type FinishConnectionSetup = (companyId: string, connectionId: string, input: FinishToolApp, actor?: ActorInfo) => Promise; diff --git a/server/src/services/tool-access/connections/organization-grant.ts b/server/src/services/tool-access/connections/organization-grant.ts new file mode 100644 index 00000000000..721398dd70d --- /dev/null +++ b/server/src/services/tool-access/connections/organization-grant.ts @@ -0,0 +1,109 @@ +import type { ToolAccessMutationDb } from "../types.js"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, toolConnections } from "@paperclipai/db"; +import { RAILWAY_SSH_SECRET_PATH } from "../../railway-ssh.js"; +import { isRailwayConnection } from "../../railway.js"; + +export function createOrganizationGrantStore(db: Db) { + async function ensureDefaultOrganizationGrant( + connection: typeof toolConnections.$inferSelect, + dbClient: ToolAccessMutationDb = db, + onMutation?: (mutation: { + previous: typeof connectionGrants.$inferSelect | null; + current: typeof connectionGrants.$inferSelect; + }) => void, + /** + * Scope provenance from the authorization that just completed, for the callers that have + * one. The default organization grant is created before OAuth has issued any credentials, + * so without this the grants API reads back a shared identity whose scope has no source + * and no over-grant warning — while the connection record carries both. + * + * Deliberately scope fields only. The shared grant takes its credential lifecycle from the + * connection, and writing `accessTokenExpiresAt` here would make an expired connection + * look fresh to the refresh-due check and skip the reconnect prompt. + */ + oauthProvenance?: { + scopes: string[]; + scopeSource: "provider" | "requested_fallback"; + unrequestedScopes: string[]; + requestedScopes: string[]; + }, + ) { + const providerTenantWithOauth = ( + current: (typeof connectionGrants.$inferSelect)["providerTenant"], + ) => + oauthProvenance + ? { + ...(current ?? {}), + oauth: { + ...(current?.oauth ?? {}), + scopes: oauthProvenance.scopes, + scopeSource: oauthProvenance.scopeSource, + unrequestedScopes: oauthProvenance.unrequestedScopes, + requestedScopes: oauthProvenance.requestedScopes, + }, + } + : current; + const [existing] = await dbClient + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "organization"), + eq(connectionGrants.isDefault, true), + ), + ) + .limit(1); + if (existing) { + // OAuth connections create their organization grant before the browser + // callback has issued any credentials. Reconnects can rotate those + // credentials later as well. Keep the execution grant synchronized with + // the connection so the gateway projects the current secrets instead of + // sending an unauthenticated request after an apparently successful + // setup. Reconnecting is also the explicit recovery path for a revoked + // shared identity, so it is correct to reactivate that default grant here. + const [updated] = await dbClient + .update(connectionGrants) + .set({ + credentialSecretRefs: isRailwayConnection(connection) + ? [...connection.credentialSecretRefs, ...existing.credentialSecretRefs.filter((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH && !connection.credentialSecretRefs.some((candidate) => candidate.configPath === ref.configPath))] + : connection.credentialSecretRefs, + ...(oauthProvenance + ? { providerTenant: providerTenantWithOauth(existing.providerTenant) } + : {}), + status: "active", + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: new Date(), + }) + .where(eq(connectionGrants.id, existing.id)) + .returning(); + if (!updated) + throw new Error("Failed to update default connection grant"); + onMutation?.({ previous: existing, current: updated }); + return updated; + } + const [created] = await dbClient + .insert(connectionGrants) + .values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "organization", + credentialSecretRefs: connection.credentialSecretRefs, + ...(oauthProvenance + ? { providerTenant: providerTenantWithOauth(null) } + : {}), + status: "active", + isDefault: true, + }) + .returning(); + if (!created) throw new Error("Failed to create default connection grant"); + onMutation?.({ previous: null, current: created }); + return created; + } + return ensureDefaultOrganizationGrant; +} diff --git a/server/src/services/tool-access/connections/personal-identity.ts b/server/src/services/tool-access/connections/personal-identity.ts new file mode 100644 index 00000000000..1d12a5b5137 --- /dev/null +++ b/server/src/services/tool-access/connections/personal-identity.ts @@ -0,0 +1,77 @@ +import type { ActorInfo } from "../types.js"; +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, toolConnections } from "@paperclipai/db"; +import { forbidden } from "../../../errors.js"; + +import { actorBinding } from "../actor.js"; +export function createPersonalConnectionIdentity(db: Db) { + /** + * Resolve the one user identity a personal-only connection is allowed to + * refresh. The connection creator is authoritative for new rows; retained + * grants cover older or agent-created rows. Reconnect may rotate that + * identity's credential, but it may never create a different user's identity + * on the same connection. + */ +async function fixedPersonalIdentityForReconnect( + connection: typeof toolConnections.$inferSelect, + requestedSubjectUserId: string | undefined, + actor?: ActorInfo, + ): Promise<{ + subjectUserId: string; + grant: typeof connectionGrants.$inferSelect | null; + } | null> { + if (connection.credentialPolicy !== "per_user") return null; + + const personalGrants = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + ), + ) + .orderBy(desc(connectionGrants.updatedAt)); + const creatorGrant = connection.createdByUserId + ? (personalGrants.find( + (grant) => grant.subjectUserId === connection.createdByUserId, + ) ?? null) + : null; + const retainedGrant = + creatorGrant ?? + personalGrants.find((grant) => grant.credentialSecretRefs.length > 0) ?? + personalGrants[0] ?? + null; + const fixedSubjectUserId = + connection.createdByUserId ?? retainedGrant?.subjectUserId ?? null; + const binding = actorBinding(actor); + const actorUserId = binding.actorType === "user" ? binding.actorId : null; + const subjectUserId = requestedSubjectUserId ?? actorUserId; + + if (!subjectUserId) { + throw forbidden( + "Reconnect this personal connection as the user it belongs to", + ); + } + if (actorUserId && subjectUserId !== actorUserId) { + throw forbidden( + "Board users may only reconnect their own personal connection", + ); + } + if (fixedSubjectUserId && subjectUserId !== fixedSubjectUserId) { + throw forbidden( + "Only the existing personal identity can reconnect this connection", + ); + } + + return { + subjectUserId, + grant: + personalGrants.find((grant) => grant.subjectUserId === subjectUserId) ?? + null, + }; + } + return fixedPersonalIdentityForReconnect; +} diff --git a/server/src/services/tool-access/connections/setup-policy.ts b/server/src/services/tool-access/connections/setup-policy.ts new file mode 100644 index 00000000000..cf07e847b2f --- /dev/null +++ b/server/src/services/tool-access/connections/setup-policy.ts @@ -0,0 +1,45 @@ +import { getAppDefinitionForApplicationKey, isConnectorFeatureEnabled } from "@paperclipai/shared"; +import { isMemoryConnectorId } from "@paperclipai/shared"; +import { instanceSettingsService } from "../../instance-settings.js"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { toolApplications, toolConnections } from "@paperclipai/db"; +import { getConnectableAppDefinition } from "@paperclipai/shared"; +import { forbidden, unprocessable } from "../../../errors.js"; +import { isRetiredComposioConnection, RETIRED_COMPOSIO_MESSAGE } from "@paperclipai/shared"; + +export function assertSupportedConnection(connection: typeof toolConnections.$inferSelect) { + if (isRetiredComposioConnection(connection)) { + throw unprocessable(RETIRED_COMPOSIO_MESSAGE, { code: "composio_broker_retired" }); + } +} + +export function createConnectionSetupPolicy(db: Db) { + async function assertExperimentalConnectorSetupEnabled(provider: unknown, existing = false) { + const app = typeof provider === "string" ? getConnectableAppDefinition(provider) : null; + if (!app?.experimentalFeatureKey && (existing || !isMemoryConnectorId(provider))) return; + const settings = await instanceSettingsService(db).getExperimental(); + if (app && !isConnectorFeatureEnabled(app, settings)) { + throw forbidden(`${app.name} is unavailable on this instance`, { code: "connector_disabled" }); + } + if (!existing && isMemoryConnectorId(provider) && !settings.enableMemoryConnectors) { + throw forbidden("Enable memory connectors in Settings → Experimental to set up this connection", { code: "memory_connectors_disabled" }); + } + } + + async function assertConnectorConnectionSetupEnabled(connection: { config?: Record; transportConfig?: Record; applicationId?: string; companyId?: string; status?: string }, applicationKey?: unknown) { + let resolvedApplicationKey = applicationKey; + if (resolvedApplicationKey == null && connection.applicationId && connection.companyId) { + const [application] = await db.select({ applicationKey: toolApplications.applicationKey }) + .from(toolApplications).where(and(eq(toolApplications.id, connection.applicationId), + eq(toolApplications.companyId, connection.companyId))).limit(1); + resolvedApplicationKey = application?.applicationKey; + } + const marker = connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey; + const app = resolvedApplicationKey != null ? getAppDefinitionForApplicationKey(resolvedApplicationKey) + : typeof marker === "string" ? getConnectableAppDefinition(marker) : null; + if (app) await assertExperimentalConnectorSetupEnabled(app.slug, connection.status !== "draft"); + } + return { assertExperimentalConnectorSetupEnabled, assertConnectorConnectionSetupEnabled }; +} +export type ConnectionSetupPolicy = ReturnType; diff --git a/server/src/services/tool-access/oauth/access-finalization.ts b/server/src/services/tool-access/oauth/access-finalization.ts new file mode 100644 index 00000000000..e27e8a67a0d --- /dev/null +++ b/server/src/services/tool-access/oauth/access-finalization.ts @@ -0,0 +1,431 @@ +import type { ActorInfo } from "../types.js"; +import { connectionMethodForConnection } from "../connection-methods.js"; +import { emitConnectionUpdated } from "../../connector-telemetry.js"; +import { and, eq, inArray, or } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrantMembers, connectionGrantDelegations, connectionGrants, companySecrets, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections } from "@paperclipai/db"; +import type { FinishToolAppResult, FinalizeOAuthAccess, ToolCredentialSecretRef } from "@paperclipai/shared"; +import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js"; +import { secretService } from "../../secrets.js"; + +import type { OAuthCredentialStore } from "./credential-store.js"; + +import type { FinishConnectionSetup } from "../catalog/contracts.js"; + +type Dependencies = { + getConnectionRow(idOrUid: string, companyId?: string): Promise; + secrets: ReturnType; + store: Pick; + assertAgentsInCompany(companyId: string, agentIds: string[]): Promise; + finishGalleryAppConnection: FinishConnectionSetup; +}; + +export function createOAuthAccessFinalization(db: Db, dependencies: Dependencies) { + const { getConnectionRow, secrets, store, assertAgentsInCompany, finishGalleryAppConnection } = dependencies; + const { createOrRotateOAuthSecret, syncCredentialBindings } = store; + /** + * Finish the one decision that cannot safely be guessed for a browser OAuth + * connection: whether the consenting identity stays personal or becomes the + * company's shared identity. The provider callback always writes a fresh + * token to the consenting user's grant first. Only this explicit endpoint may + * promote it to company-scoped secrets. + */ +async function finalizeOAuthAccess( + companyId: string, + connectionId: string, + input: FinalizeOAuthAccess, + actor?: ActorInfo, + requestingAgentId?: string, + ): Promise { + if (requestingAgentId) + await assertAgentsInCompany(companyId, [requestingAgentId]); + let connection = await getConnectionRow(connectionId, companyId); + const preFinalizeLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; + if (connection.authKind !== "oauth") + throw badRequest("This connection does not use browser sign-in"); + if (connection.status === "archived") + throw conflict("Archived app connections cannot be finished"); + const actorUserId = actor?.actorType === "user" ? actor.actorId : null; + if (!actorUserId) + throw badRequest("Finishing browser sign-in requires a signed-in user"); + + const [personalGrant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, actorUserId), + ), + ) + .limit(1); + + if (connection.credentialSource === "vercel_connect") { + const [organizationGrant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "organization"), + eq(connectionGrants.isDefault, true), + ), + ) + .limit(1); + const selectedGrant = + input.grantKind === "user" ? personalGrant : organizationGrant; + if ( + !selectedGrant || + selectedGrant.status !== "active" || + !selectedGrant.externalCredential + ) { + throw conflict( + "The selected Vercel Connect identity is missing. Authorize this connection again.", + ); + } + const expectedPolicy = input.grantKind === "user" ? "per_user" : "shared"; + if (connection.credentialPolicy !== expectedPolicy) { + throw conflict( + "Vercel Connect identity scope is fixed when the connector is attached. Create a new connection to change it.", + ); + } + } else if (input.grantKind === "user") { + if ( + !personalGrant || + personalGrant.status !== "active" || + personalGrant.credentialSecretRefs.length === 0 + ) { + throw conflict( + "Your connected identity is missing. Connect this app again before choosing Just me.", + ); + } + if ( + connection.credentialPolicy === "shared" && + connection.credentialSecretRefs.length > 0 + ) { + throw conflict("This connection already uses a company identity"); + } + [connection] = await db + .update(toolConnections) + .set({ + credentialPolicy: "per_user", + credentialRefs: connection.credentialRefs.filter( + (ref) => ref.name !== "oauth.access_token", + ), + credentialSecretRefs: connection.credentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ), + status: "active", + enabled: true, + updatedAt: new Date(), + }) + .where( + and( + eq(toolConnections.id, connection.id), + eq(toolConnections.companyId, companyId), + ), + ) + .returning(); + await syncCredentialBindings( + connection, + personalGrant.credentialSecretRefs, + ); + } else if ( + connection.credentialPolicy !== "shared" || + connection.credentialSecretRefs.length === 0 + ) { + if ( + !personalGrant || + personalGrant.status !== "active" || + personalGrant.credentialSecretRefs.length === 0 + ) { + throw conflict( + "Your connected identity is missing. Connect this app again before sharing it.", + ); + } + + const personalSecretIds = personalGrant.credentialSecretRefs.map( + (ref) => ref.secretId, + ); + const personalSecretRows = await db + .select({ + id: companySecrets.id, + scope: companySecrets.scope, + ownerUserId: companySecrets.ownerUserId, + userSecretDefinitionId: companySecrets.userSecretDefinitionId, + }) + .from(companySecrets) + .where( + and( + eq(companySecrets.companyId, companyId), + inArray(companySecrets.id, personalSecretIds), + ), + ); + const personalSecretById = new Map( + personalSecretRows.map((row) => [row.id, row]), + ); + const promotedRefs: ToolCredentialSecretRef[] = []; + try { + for (const ref of personalGrant.credentialSecretRefs) { + const secretRow = personalSecretById.get(ref.secretId); + if ( + !secretRow || + secretRow.scope !== "user" || + secretRow.ownerUserId !== actorUserId || + !secretRow.userSecretDefinitionId + ) { + throw forbidden( + "Only your own connected identity can be shared with the company", + ); + } + if ( + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token" && + ref.configPath !== "oauth.client_secret" + ) { + throw badRequest( + "The connected identity contains an unsupported OAuth credential", + ); + } + const resolved = await secrets.resolveUserSecretValue( + companyId, + { + definitionId: secretRow.userSecretDefinitionId, + responsibleUserId: actorUserId, + version: ref.versionSelector ?? "latest", + }, + { + consumerType: "tool_connection", + consumerId: connection.id, + responsibleUserId: actorUserId, + actorType: "user", + actorId: actorUserId, + }, + ); + if (!resolved) + throw unprocessable("The connected identity could not be read"); + const promoted = await createOrRotateOAuthSecret({ + companyId, + connection, + configPath: ref.configPath, + label: ref.label ?? "OAuth credential", + value: resolved.value, + actor, + existingRefs: promotedRefs, + }); + promotedRefs.push({ ...ref, ...promoted }); + } + + const accessRef = promotedRefs.find( + (ref) => ref.configPath === "oauth.access_token", + ); + if (!accessRef) + throw unprocessable( + "The connected identity is missing its OAuth access token", + ); + const connectionCredentialSecretRefs = [ + ...connection.credentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ), + ...promotedRefs, + ]; + const nowAt = new Date(); + await db.transaction(async (tx) => { + await tx.select({ id: connectionGrants.id }).from(connectionGrants) + .where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connection.id), + or(eq(connectionGrants.id, personalGrant.id), and(eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true))))) + .orderBy(connectionGrants.id).for("update"); + await tx.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))) + .for("update"); + await tx.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))) + .for("update"); + const [existingOrganizationGrant] = await tx + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "organization"), + eq(connectionGrants.isDefault, true), + ), + ) + .limit(1); + let organizationGrantId: string; + if (existingOrganizationGrant) { + organizationGrantId = existingOrganizationGrant.id; + await tx + .update(connectionGrants) + .set({ + providerTenant: personalGrant.providerTenant, + credentialSecretRefs: connectionCredentialSecretRefs, + status: "active", + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: nowAt, + }) + .where(eq(connectionGrants.id, existingOrganizationGrant.id)); + } else { + const [createdOrganizationGrant] = await tx + .insert(connectionGrants) + .values({ + companyId, + connectionId: connection.id, + kind: "organization", + subjectUserId: null, + providerTenant: personalGrant.providerTenant, + credentialSecretRefs: connectionCredentialSecretRefs, + status: "active", + isDefault: true, + createdByUserId: actorUserId, + }) + .returning({ id: connectionGrants.id }); + organizationGrantId = createdOrganizationGrant.id; + } + // Empty audience rows are the canonical "everyone in the company". + await tx + .delete(connectionGrantMembers) + .where( + and( + eq(connectionGrantMembers.companyId, companyId), + eq(connectionGrantMembers.grantId, organizationGrantId), + ), + ); + await tx + .delete(connectionGrantDelegations) + .where( + and( + eq(connectionGrantDelegations.companyId, companyId), + eq(connectionGrantDelegations.grantId, personalGrant.id), + ), + ); + await tx + .update(connectionGrants) + .set({ + credentialSecretRefs: [], + status: "revoked", + revokedAt: nowAt, + revokedByUserId: actorUserId, + updatedAt: nowAt, + }) + .where(eq(connectionGrants.id, personalGrant.id)); + [connection] = await tx + .update(toolConnections) + .set({ + credentialPolicy: "shared", + credentialSecretRefs: connectionCredentialSecretRefs, + credentialRefs: [ + { + name: "oauth.access_token", + secretId: accessRef.secretId, + version: "latest", + placement: "header", + key: "Authorization", + prefix: "Bearer ", + }, + ], + status: "active", + enabled: true, + updatedAt: nowAt, + }) + .where( + and( + eq(toolConnections.id, connection.id), + eq(toolConnections.companyId, companyId), + ), + ) + .returning(); + }); + } catch (error) { + await Promise.all( + promotedRefs.map((ref) => + secrets.remove(ref.secretId).catch(() => undefined), + ), + ); + throw error; + } + await syncCredentialBindings(connection); + // The source values are no longer referenced by either grant or + // connection. Removing them makes the promotion a move, not a copy. + for (const secretId of personalSecretIds) await secrets.remove(secretId); + } + + // Both identity branches above commit their own lifecycle write (draft -> + // active) before `finishGalleryAppConnection` re-reads the row, so that + // step alone would never observe this transition. This is the OAuth + // access-finalization step of the same gallery setup flow (Apps and inline + // task cards both reach it), hence `gallery`. + emitConnectionUpdated(connection, preFinalizeLifecycle, "gallery"); + const catalog = await db + .select() + .from(toolCatalogEntries) + .where( + and( + eq(toolCatalogEntries.companyId, companyId), + eq(toolCatalogEntries.connectionId, connection.id), + eq(toolCatalogEntries.status, "active"), + ), + ); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + const defaults = galleryEntry + ? recommendedDefaultsForApp( + galleryEntry, + connectionMethodForConnection(galleryEntry, connection).key, + ) + : { askFirstRiskLevels: [] }; + const askFirstRiskLevels = new Set( + Array.isArray(defaults.askFirstRiskLevels) + ? defaults.askFirstRiskLevels.filter( + (value): value is string => typeof value === "string", + ) + : [], + ); + const finished = await finishGalleryAppConnection( + companyId, + connection.id, + { + enabledCatalogEntryIds: catalog.map((entry) => entry.id), + askFirstCatalogEntryIds: catalog + .filter((entry) => askFirstRiskLevels.has(entry.riskLevel)) + .map((entry) => entry.id), + access: requestingAgentId + ? { agentIds: [requestingAgentId] } + : "all_agents", + preserveExistingAccess: Boolean(requestingAgentId), + }, + actor, + ); + await db + .insert(toolConnectionInstalls) + .values({ + companyId, + connectionId: connection.id, + targetType: requestingAgentId ? "agent" : "company", + targetId: requestingAgentId ?? companyId, + createdByUserId: actorUserId, + }) + .onConflictDoNothing(); + return finished; + } + return finalizeOAuthAccess; +} diff --git a/server/src/services/tool-access/oauth/authorization-constraints.ts b/server/src/services/tool-access/oauth/authorization-constraints.ts new file mode 100644 index 00000000000..0e648d0ae78 --- /dev/null +++ b/server/src/services/tool-access/oauth/authorization-constraints.ts @@ -0,0 +1,79 @@ +import { oauthConfig, sameOAuthIssuer } from "./metadata.js"; +import type { OAuthProviderEndpoints } from "./types.js"; +import { createHash, randomBytes } from "node:crypto"; +import { toolConnections } from "@paperclipai/db"; +import type { AppDefinition } from "@paperclipai/shared"; +import { badRequest, unprocessable } from "../../../errors.js"; + +export function assertOAuthRedirectConstraints( + app: AppDefinition | null, + redirectUri: string, +) { + if (app?.redirectConstraints !== "https-or-loopback-http") return; + let redirect: URL; + try { + redirect = new URL(redirectUri); + } catch { + throw unprocessable("OAuth callback URL is invalid", { + code: "oauth_redirect_uri_invalid", + }); + } + const hostname = redirect.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const isLoopback = + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + /^127(?:\.\d{1,3}){3}$/.test(hostname); + if ( + redirect.protocol === "https:" || + (redirect.protocol === "http:" && isLoopback) + ) + return; + throw unprocessable( + "This provider requires an HTTPS or loopback origin. Configure TLS before connecting.", + { + code: "oauth_redirect_origin_unsupported", + redirectConstraints: app.redirectConstraints, + docsPath: "docs/deploy", + }, + ); +} + +/** + * Validate RFC 9207 `iss` on the authorization callback. + * + * When an authorization server returns `iss`, it must name the same issuer the + * authorization request was bound to. A mismatch means the code came back from a + * different server than the one we sent the user to — the mix-up attack RFC 9207 + * exists to stop — so the code is refused rather than exchanged. An absent `iss` + * is tolerated: it is optional, and many deployed servers omit it. + */ +export function assertOAuthCallbackIssuer( + connection: typeof toolConnections.$inferSelect, + endpoints: OAuthProviderEndpoints, + iss: string | null | undefined, +) { + const returnedIssuer = typeof iss === "string" ? iss.trim() : ""; + if (!returnedIssuer) return; + const oauth = oauthConfig(connection); + const expectedIssuer = + typeof oauth.expectedIssuer === "string" && oauth.expectedIssuer.trim() + ? oauth.expectedIssuer.trim() + : (endpoints.issuer ?? null); + if (!expectedIssuer) return; + if (sameOAuthIssuer(returnedIssuer, expectedIssuer)) return; + throw badRequest( + "Sign-in came back from an unexpected server. Start the connection again.", + { + code: "oauth_issuer_mismatch", + }, + ); +} + +export function randomOauthToken(bytes = 32) { + return randomBytes(bytes).toString("base64url"); +} + +export function base64UrlSha256(input: string) { + return createHash("sha256").update(input).digest("base64url"); +} diff --git a/server/src/services/tool-access/oauth/authorization-start.ts b/server/src/services/tool-access/oauth/authorization-start.ts new file mode 100644 index 00000000000..823b79eb5ef --- /dev/null +++ b/server/src/services/tool-access/oauth/authorization-start.ts @@ -0,0 +1,655 @@ +import { vercelConnectHttpError, vercelCredentialFor, vercelConnectResourcesFor } from "../vercel-credentials.js"; +import { credentialScope, managedConnectorProfile } from "./client-settings.js"; +import { createOAuthClientRegistration } from "./client-registration.js"; +import type { ActorInfo } from "../types.js"; +import { createOAuthEndpointPolicy } from "./endpoint-policy.js"; +import { createOAuthDiscovery } from "./discovery.js"; +import { oauthConfig, normalizeOauthScopes, originOf } from "./metadata.js"; +import { connectionMethodForConnection } from "../connection-methods.js"; +import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared"; +import { and, eq, lt } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { agents, connectionGrants, issueThreadInteractions, toolConnections, toolOauthStates } from "@paperclipai/db"; +import type { ConnectionGrantKind, ToolOAuthStartResult } from "@paperclipai/shared"; +import { getConnectableAppDefinition } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, unprocessable } from "../../../errors.js"; +import { isPaperclipCloudConnectorStrategy, type PaperclipCloudConnector } from "../../paperclip-cloud-connector.js"; +import { deriveVercelConnectSubject, vercelConnectCallbackUrl, VercelConnectClientError, type VercelConnectClient } from "../../vercel-connect.js"; + +import type { ConnectionSetupPolicy } from "../connections/setup-policy.js"; +import { assertSupportedConnection } from "../connections/setup-policy.js"; + +import { actorBinding } from "../actor.js"; +import { assertOAuthRedirectConstraints, randomOauthToken, base64UrlSha256 } from "./authorization-constraints.js"; +import type { createPersonalConnectionIdentity } from "../connections/personal-identity.js"; + +type Dependencies = { + now: () => Date; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + setupPolicy: ConnectionSetupPolicy; + fixedPersonalIdentityForReconnect: ReturnType; + currentCloudConnector: () => PaperclipCloudConnector | null; + vercelConnect: VercelConnectClient | null; + ensureOAuthClient: ReturnType; + oauthEndpointsForConnection: ReturnType["oauthEndpointsForConnection"]; + assertOAuthEndpointUrl: ReturnType["assertOAuthEndpointUrl"]; +}; + +export function createOAuthAuthorizationStart(db: Db, dependencies: Dependencies) { + const { now, getConnectionRow, setupPolicy, fixedPersonalIdentityForReconnect, currentCloudConnector, vercelConnect, ensureOAuthClient, oauthEndpointsForConnection, assertOAuthEndpointUrl } = dependencies; + const { assertConnectorConnectionSetupEnabled, assertExperimentalConnectorSetupEnabled } = setupPolicy; + async function startOAuth( + companyId: string, + connectionId: string, + input: { + redirectUri: string; + actor: ActorInfo; + subjectUserId?: string; + subjectAgentId?: string; + scopes?: string[]; + workspaceServices?: GoogleWorkspaceServiceSelection; + returnTo?: string; + issueId?: string; + interactionId?: string; + }, + ): Promise { + let connection = await getConnectionRow(connectionId, companyId); + assertSupportedConnection(connection); + await assertConnectorConnectionSetupEnabled(connection); + await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); + if (connection.status === "archived") + throw conflict("Archived app connections cannot start sign in"); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + assertOAuthRedirectConstraints(galleryEntry, input.redirectUri); + const galleryMethod = galleryEntry + ? connectionMethodForConnection(galleryEntry, connection) + : null; + const requestedScopes = await (async () => { + if (input.workspaceServices && sourceTemplateKey !== "google-workspace") throw badRequest("Service selection requires Google Workspace"); + if (sourceTemplateKey === "google-workspace") { + if (input.workspaceServices && input.scopes) throw badRequest("Choose services or scopes, not both"); + const allowed = normalizeOauthScopes(galleryMethod?.defaults?.scopesHint); + let scopes = input.scopes ?? (input.workspaceServices ? googleWorkspaceSelectedScopes(input.workspaceServices) : undefined); + if (!scopes && connection.status !== "draft") { + // Account-page reconnect has no new service choice. Never replace its + // subject's saved request with the new-account write defaults. + const binding = actorBinding(input.actor); + const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null); + const kind = connection.credentialPolicy === "per_user" ? "user" + : connection.credentialPolicy === "per_agent" ? "agent" : "organization"; + const [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, kind), + kind === "user" ? eq(connectionGrants.subjectUserId, subjectUserId ?? "") + : kind === "agent" ? eq(connectionGrants.subjectAgentId, input.subjectAgentId ?? "") + : eq(connectionGrants.isDefault, true), + )).limit(1); + const saved = grant?.providerTenant?.oauth; + scopes = normalizeOauthScopes(saved?.requestedScopes ?? saved?.scopes) + .filter(scope => allowed.includes(scope)); + if (scopes.length === 0) throw badRequest("Choose your Google Workspace services before reconnecting"); + } + scopes ??= googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); + if (!isGoogleWorkspaceScopeGrant(scopes) || scopes.some(scope => !allowed.includes(scope))) throw badRequest("Select at least one supported Google service"); + return scopes; + } + if (!galleryMethod) return input.scopes ?? null; + const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint); + if (!input.scopes) return allowed; + const requested = normalizeOauthScopes(input.scopes); + const widened = requested.filter((scope) => !allowed.includes(scope)); + if (widened.length > 0) { + throw badRequest( + `Requested OAuth scopes are not allowed for ${galleryEntry?.name ?? "this app"}`, + { + code: "oauth_scope_widening_rejected", + scopes: widened, + }, + ); + } + return requested; + })(); + const starterBinding = actorBinding(input.actor); + const fixedPersonalIdentity = await fixedPersonalIdentityForReconnect( + connection, + input.subjectUserId, + input.actor, + ); + const authorizationSubjectUserId = + fixedPersonalIdentity?.subjectUserId ?? input.subjectUserId; + const authorizationSubjectAgentId = input.subjectAgentId; + if (authorizationSubjectAgentId) { + const [subjectAgent] = await db + .select({ id: agents.id }) + .from(agents) + .where( + and( + eq(agents.id, authorizationSubjectAgentId), + eq(agents.companyId, companyId), + ), + ) + .limit(1); + if (!subjectAgent) + throw badRequest( + "Dedicated identity requires an agent in this company", + ); + if (connection.credentialPolicy !== "per_agent") { + throw badRequest( + "This connection is not configured for a dedicated agent identity", + ); + } + } + const intentLink = input.interactionId + ? await db + .select({ + id: issueThreadInteractions.id, + issueId: issueThreadInteractions.issueId, + addresseeUserId: issueThreadInteractions.addresseeUserId, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.id, input.interactionId), + eq(issueThreadInteractions.companyId, companyId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + if ( + input.interactionId && + (!intentLink || + intentLink.kind !== "connection_intent" || + intentLink.status !== "pending" || + starterBinding.actorType !== "user" || + starterBinding.actorId !== intentLink.addresseeUserId) + ) { + throw forbidden( + "Only the addressed user can authorize this connection request", + ); + } + if (connection.credentialSource === "vercel_connect") { + if (!vercelConnect) + throw vercelConnectHttpError( + new VercelConnectClientError("vercel_connect_unavailable", 503), + ); + const credential = vercelCredentialFor(connection); + if ( + connection.authKind !== "oauth" || + credential.principalMode !== "user" + ) { + throw badRequest( + "This Vercel connector does not use browser authorization", + ); + } + const binding = starterBinding; + if (!binding.actorType || !binding.actorId) { + throw forbidden( + "Vercel Connect authorization requires an authenticated actor", + ); + } + const grantKind: ConnectionGrantKind = authorizationSubjectUserId + ? "user" + : "organization"; + const derived = deriveVercelConnectSubject({ + credential, + connectionId: connection.id, + companyId, + grantKind, + subjectUserId: authorizationSubjectUserId, + }); + const state = randomOauthToken(); + let authorization; + try { + authorization = await vercelConnect.startAuthorization( + { + connector: credential.connectorUid, + subject: derived.subject, + scopes: credential.scopes, + resources: vercelConnectResourcesFor(connection), + }, + vercelConnectCallbackUrl(input.redirectUri, state), + ); + } catch (error) { + throw vercelConnectHttpError(error); + } + const remoteExpiry = authorization.expiresAt + ? new Date(authorization.expiresAt) + : null; + const expiresAt = + remoteExpiry && Number.isFinite(remoteExpiry.getTime()) + ? new Date( + Math.min( + remoteExpiry.getTime(), + now().getTime() + 10 * 60 * 1000, + ), + ) + : new Date(now().getTime() + 10 * 60 * 1000); + await db + .delete(toolOauthStates) + .where(lt(toolOauthStates.expiresAt, now())); + await db.insert(toolOauthStates).values({ + state, + companyId, + connectionId: connection.id, + // Vercel owns its verifier. This marker only dispatches the consumed + // one-time state to the Vercel callback completion path. + codeVerifier: "vercel-connect", + createdByActorType: binding.actorType, + createdByActorId: binding.actorId, + createdBySessionId: binding.sessionId, + subjectUserId: authorizationSubjectUserId, + requestedScopes: [...credential.scopes], + returnTo: input.returnTo, + issueId: intentLink?.issueId ?? input.issueId, + interactionId: intentLink?.id, + expiresAt, + }); + return { + connectionId: connection.id, + provider: `vercel-connect:${credential.service}`, + authorizationUrl: authorization.url, + expiresAt: expiresAt.toISOString(), + issuer: "https://vercel.com/connect", + resource: galleryMethod?.defaults?.serverUrl ?? null, + registrationSource: null, + }; + } + if ( + galleryMethod && + isPaperclipCloudConnectorStrategy(galleryMethod.oauthStrategy) + ) { + const connectorProfile = galleryMethod.connectorProfile; + const managedProfile = managedConnectorProfile(connectorProfile); + if (!managedProfile) + throw badRequest("This app has an invalid managed connector profile"); + const providerName = galleryEntry?.name ?? "Google Workspace"; + const cloudConnector = currentCloudConnector(); + if (!cloudConnector) { + throw unprocessable( + `${providerName} connections through Paperclip are not available on this instance yet`, + { + code: "paperclip_cloud_connector_unavailable", + }, + ); + } + const binding = starterBinding; + if (!binding.actorType || !binding.actorId) { + throw forbidden( + `${providerName} sign-in requires an authenticated actor`, + ); + } + const subjectUserId = authorizationSubjectAgentId + ? null + : (authorizationSubjectUserId ?? + (binding.actorType === "user" ? binding.actorId : null)); + if (!subjectUserId && !authorizationSubjectAgentId) { + throw forbidden( + `Agent-started ${providerName} sign-in requires an authorized identity`, + ); + } + if ( + !authorizationSubjectAgentId && + binding.actorType === "user" && + subjectUserId !== binding.actorId + ) { + throw forbidden( + `Board users may only authorize their own ${providerName} identity`, + ); + } + await db + .delete(toolOauthStates) + .where(lt(toolOauthStates.expiresAt, now())); + const state = randomOauthToken(); + const returnUri = new URL(input.redirectUri); + returnUri.pathname = "/api/tools/oauth/cloud-connector/callback"; + returnUri.search = ""; + returnUri.hash = ""; + const session = await cloudConnector.startAuthorization({ + subject: authorizationSubjectAgentId + ? `agent:${authorizationSubjectAgentId}` + : subjectUserId!, + companyId, + profile: managedProfile.id, + ...(managedProfile.id === "workspace.all" ? { scopes: requestedScopes! } : {}), + returnUri: returnUri.toString(), + returnState: state, + }); + const remoteExpiry = new Date(session.expiresAt); + const expiresAt = Number.isFinite(remoteExpiry.getTime()) + ? new Date( + Math.min(remoteExpiry.getTime(), now().getTime() + 10 * 60 * 1000), + ) + : new Date(now().getTime() + 10 * 60 * 1000); + await db.insert(toolOauthStates).values({ + state, + companyId, + connectionId: connection.id, + // Paperclip Cloud owns PKCE for this flow. The local state row remains the + // single-use browser correlator and never stores broker token material. + codeVerifier: "paperclip-cloud-connector", + createdByActorType: binding.actorType, + createdByActorId: binding.actorId, + createdBySessionId: binding.sessionId, + subjectUserId, + subjectAgentId: authorizationSubjectAgentId, + requestedScopes: managedProfile.id === "workspace.all" ? requestedScopes! : [...managedProfile.scopes], + returnTo: input.returnTo, + issueId: intentLink?.issueId ?? input.issueId, + interactionId: intentLink?.id, + expiresAt, + }); + return { + connectionId: connection.id, + provider: managedProfile.provider, + authorizationUrl: session.authorizationUrl, + expiresAt: expiresAt.toISOString(), + ...(session.handoff ? { handoff: session.handoff } : {}), + issuer: + managedProfile.provider === "github" + ? "https://github.com" + : managedProfile.provider === "asana" + ? "https://app.asana.com" + : "https://accounts.google.com", + resource: galleryMethod.defaults?.serverUrl ?? null, + registrationSource: null, + }; + } + const endpoints = await oauthEndpointsForConnection( + connection, + null, + input.redirectUri, + ); + if (endpoints.grantType === "client_credentials") { + throw unprocessable( + "This app uses shared machine credentials and does not need browser sign in", + ); + } + const resolvedClient = await ensureOAuthClient({ + connection, + endpoints, + redirectUri: input.redirectUri, + galleryEntry, + actor: input.actor, + }); + 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}`, + ); + + const authorizationScopes = normalizeOauthScopes( + galleryMethod ? (requestedScopes ?? []) : (input.scopes ?? endpoints.scopes), + ); + if ( + !galleryMethod && + endpoints.offlineAccessSupported && + (!endpoints.grantTypesSupported?.length || + endpoints.grantTypesSupported.includes("refresh_token")) && + !authorizationScopes.includes("offline_access") + ) authorizationScopes.push("offline_access"); + + await db + .delete(toolOauthStates) + .where(lt(toolOauthStates.expiresAt, new Date())); + + const state = randomOauthToken(); + const codeVerifier = randomOauthToken(48); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); + const binding = starterBinding; + if (!binding.actorType || !binding.actorId) { + throw forbidden("OAuth sign-in requires an authenticated board session"); + } + await db.insert(toolOauthStates).values({ + state, + companyId, + connectionId: connection.id, + codeVerifier, + createdByActorType: binding.actorType, + createdByActorId: binding.actorId, + createdBySessionId: binding.sessionId, + subjectUserId: authorizationSubjectUserId, + requestedScopes: authorizationScopes, + returnTo: input.returnTo, + issueId: intentLink?.issueId ?? input.issueId, + interactionId: intentLink?.id, + expiresAt, + }); + + // Last gate before this URL becomes a top-level browser navigation. Every + // producer above already validates, so reaching a rejection here means a new + // path was added without one — fail closed rather than hand the board an + // unvetted target. + const authorizationUrl = new URL( + assertOAuthEndpointUrl("authorization", endpoints.authorizationUrl, { + // Paperclip's own callback origin: a first-party authorization endpoint is + // served however this deployment is served, plaintext LAN host included. + firstPartyOrigin: originOf(input.redirectUri), + }), + ); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("client_id", client.clientId); + authorizationUrl.searchParams.set("redirect_uri", input.redirectUri); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set( + "code_challenge", + base64UrlSha256(codeVerifier), + ); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + // RFC 8707: name the MCP server the resulting token is for, so an + // authorization server that serves several resources can audience-restrict it. + if (endpoints.resource) + authorizationUrl.searchParams.set("resource", endpoints.resource); + // Curated definitions are an allowlist, not a suggestion. Never copy every + // scope advertised by discovery into a provider consent screen: a curated + // method either sends its reviewed hint or omits scope entirely. Generic + // MCP URLs retain discovery-first behavior because Paperclip has no manifest + // against which it could safely judge the caller's requested scope. + if (authorizationScopes.length > 0) + authorizationUrl.searchParams.set("scope", authorizationScopes.join(" ")); + if (galleryMethod && connection.config.sourceTemplateKey === "google-workspace") + authorizationUrl.searchParams.set("enable_granular_consent", "true"); + const reviewedAuthorizationParams = + galleryMethod?.defaults?.oauthAuthorizationParams; + if (reviewedAuthorizationParams?.access_type) + authorizationUrl.searchParams.set( + "access_type", + reviewedAuthorizationParams.access_type, + ); + if (reviewedAuthorizationParams?.prompt) + authorizationUrl.searchParams.set( + "prompt", + reviewedAuthorizationParams.prompt, + ); + else if (!galleryMethod && authorizationScopes.includes("offline_access")) + // OIDC offline access requires consent unless the provider has another + // established basis for granting it. A generic server has no reviewed override. + authorizationUrl.searchParams.set("prompt", "consent"); + + if ( + authorizationSubjectUserId && + input.issueId && + binding.actorType === "agent" + ) { + const idempotencyKey = `connection-authorization:${connection.id}:${authorizationSubjectUserId}`; + // Provider label for the card's copy. The gallery definition's name when we + // have one, else the connection's own name — never a secret name or ref. + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const providerName = + (sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey)?.name + : null) ?? connection.name; + const [requestingAgent] = binding.actorId + ? await db + .select({ name: agents.name }) + .from(agents) + .where( + and( + eq(agents.id, binding.actorId), + eq(agents.companyId, companyId), + ), + ) + .limit(1) + : [undefined]; + const payload = { + version: 1 as const, + prompt: `Connect your ${providerName} to continue`, + acceptLabel: `Connect ${providerName}`, + rejectLabel: "Not now", + detailsMarkdown: + "Authorization is required before this agent can act on your behalf.", + // Presentation metadata so the card can compose its own copy instead of + // parsing the title string (PAP-17835 seam #6). The interaction kind and + // the server-addressed audience are unchanged. + connectionAuthorization: { + version: 1 as const, + providerName, + connectionName: + connection.name === providerName ? null : connection.name, + requestingAgentName: requestingAgent?.name ?? null, + }, + target: { + type: "custom" as const, + key: `connection:${connection.uid}:user:${authorizationSubjectUserId}`, + revisionId: state, + label: `Connect ${providerName}`, + href: authorizationUrl.toString(), + }, + }; + const [existingInteraction] = await db + .select() + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, companyId), + eq(issueThreadInteractions.issueId, input.issueId), + eq(issueThreadInteractions.idempotencyKey, idempotencyKey), + ), + ) + .limit(1); + const [interaction] = existingInteraction + ? await db + .update(issueThreadInteractions) + .set({ + status: "pending", + requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", + resolverPolicyProvenance: "explicit", + effectiveResolverPolicySource: "requested", + addresseeUserId: authorizationSubjectUserId, + payload, + result: null, + resolvedAt: null, + updatedAt: new Date(), + }) + .where(eq(issueThreadInteractions.id, existingInteraction.id)) + .returning() + : await db + .insert(issueThreadInteractions) + .values({ + companyId, + issueId: input.issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "none", + requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", + resolverPolicyProvenance: "explicit", + effectiveResolverPolicySource: "requested", + addresseeUserId: authorizationSubjectUserId, + idempotencyKey, + sourceRunId: + binding.actorType === "agent" + ? (input.actor.sessionId ?? null) + : null, + title: `Connect your ${providerName} to continue`, + summary: `${requestingAgent?.name ?? "An agent"} needs your ${providerName} identity for work running as you.`, + createdByAgentId: + binding.actorType === "agent" ? binding.actorId : null, + payload, + }) + .returning(); + if (interaction) { + await db + .update(toolOauthStates) + .set({ interactionId: interaction.id }) + .where(eq(toolOauthStates.state, state)); + } + } + + const nextConfig = { + ...connection.config, + oauth: { + ...oauthConfig(connection), + provider: endpoints.provider, + authorizationUrl: endpoints.authorizationUrl, + tokenUrl: endpoints.tokenUrl, + registrationUrl: endpoints.registrationUrl ?? null, + metadataUrl: endpoints.metadataUrl ?? null, + // Curated apps persist only the reviewed scopes attached to this OAuth + // state. Discovery metadata can advertise a provider's entire scope + // universe and must never silently become Paperclip's requested set. + scopes: authorizationScopes, + offlineAccessSupported: endpoints.offlineAccessSupported, + grantTypesSupported: endpoints.grantTypesSupported ?? [], + codeChallengeMethodsSupported: + endpoints.codeChallengeMethodsSupported ?? [], + tokenEndpointAuthMethodsSupported: + endpoints.tokenEndpointAuthMethodsSupported ?? [], + grantType: "authorization_code", + clientIdEnv: client.clientIdEnv, + clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null, + credentialScope: credentialScope(connection, input.actor), + // Persist the issuer and resource this authorization run is bound to. + // `iss` on the callback is validated against `expectedIssuer`, and + // refresh/reconnect/revoke reuse the same pair rather than re-deriving it. + issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, + expectedIssuer: endpoints.issuer ?? null, + resource: + endpoints.resource ?? oauthConfig(connection).resource ?? null, + clientIdMetadataDocumentSupported: + endpoints.clientIdMetadataDocumentSupported === true, + }, + }; + await db + .update(toolConnections) + .set({ + // A generic URL connection starts life as `authKind: none`. Once it has + // completed OAuth discovery and client resolution it is an OAuth + // connection, and refresh, reconnect, revoke and diagnostics must all + // treat it as one. + authKind: "oauth", + config: nextConfig, + transportConfig: nextConfig, + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, connection.id)); + + return { + connectionId: connection.id, + provider: endpoints.provider, + authorizationUrl: authorizationUrl.toString(), + expiresAt: expiresAt.toISOString(), + issuer: endpoints.issuer ?? null, + resource: endpoints.resource ?? null, + registrationSource: resolvedClient.source, + }; + } + return startOAuth; +} diff --git a/server/src/services/tool-access/oauth/catalog-completion.ts b/server/src/services/tool-access/oauth/catalog-completion.ts new file mode 100644 index 00000000000..95d293cd1b2 --- /dev/null +++ b/server/src/services/tool-access/oauth/catalog-completion.ts @@ -0,0 +1,153 @@ +import type { ActorInfo } from "../types.js"; +import { isRemoteMcpConnectorMethod } from "@paperclipai/shared"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { issueThreadInteractions, toolConnectionInstalls, toolConnections } from "@paperclipai/db"; +import type { ConnectToolAppResult, FinishToolApp, ToolCatalogEntry } from "@paperclipai/shared"; + +import type { FinishConnectionSetup } from "../catalog/contracts.js"; +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +export function createOAuthCatalogCompletion(db: Db, finishGalleryAppConnection: FinishConnectionSetup) { + async function finishOAuthCatalogWithRecommendedDefaults(input: { + connection: typeof toolConnections.$inferSelect; + catalog: ToolCatalogEntry[]; + suggestedDefaults: ConnectToolAppResult["suggestedDefaults"]; + activateQuarantined?: boolean; + actor?: ActorInfo; + interactionId?: string | null; + }) { + const linkedInteraction = input.interactionId + ? await db + .select({ kind: issueThreadInteractions.kind }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.id, input.interactionId), + eq(issueThreadInteractions.companyId, input.connection.companyId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null) + : null; + // A task callback only prepares the catalog. The intent completion transaction + // validates current ownership and adds the requesting agent's access. + const deferTaskAccess = linkedInteraction?.kind === "connection_intent"; + const installs = await db + .select() + .from(toolConnectionInstalls) + .where( + and( + eq(toolConnectionInstalls.companyId, input.connection.companyId), + eq(toolConnectionInstalls.connectionId, input.connection.id), + ), + ); + const companyInstall = installs.some( + (install) => install.targetType === "company", + ); + const agentIds = installs + .filter((install) => install.targetType === "agent") + .map((install) => install.targetId); + const suggestedAccess = input.suggestedDefaults.access; + const suggestedAccessRecord = asRecord(suggestedAccess); + const suggestedAgentIds = Array.isArray(suggestedAccessRecord.agentIds) + ? suggestedAccessRecord.agentIds.filter( + (agentId): agentId is string => typeof agentId === "string", + ) + : []; + const normalizedSuggestedAccess: FinishToolApp["access"] = + suggestedAccess === "all_agents" + ? "all_agents" + : suggestedAgentIds.length > 0 + ? { agentIds: suggestedAgentIds } + : "all_agents"; + // Workspace setup saves the operator's install selection before OAuth too. + // An empty selection means no agents, not permission to apply all-agent + // defaults after the user returns from Google. + const preserveEmptyInstalls = input.connection.config.sourceTemplateKey === "google-workspace" + || isRemoteMcpConnectorMethod( + input.connection.config.sourceTemplateKey, + input.connection.config.connectionMethodKey, + ) || (input.connection.transport === "mcp_remote" + && input.connection.config.mcpAgentAccessConfigured === true); + const access: FinishToolApp["access"] = deferTaskAccess + ? { agentIds: [] } + : installs.length === 0 + ? preserveEmptyInstalls ? { agentIds: [] } : normalizedSuggestedAccess + : companyInstall + ? "all_agents" + : { agentIds }; + const askFirstRiskLevels = new Set( + Array.isArray(input.suggestedDefaults.askFirstRiskLevels) + ? input.suggestedDefaults.askFirstRiskLevels.filter( + (riskLevel): riskLevel is string => typeof riskLevel === "string", + ) + : [], + ); + const enabledCatalog = input.catalog.filter( + (entry) => + entry.status === "active" || + (input.activateQuarantined === true && entry.status === "quarantined"), + ); + const finished = await finishGalleryAppConnection( + input.connection.companyId, + input.connection.id, + { + enabledCatalogEntryIds: enabledCatalog.map((entry) => entry.id), + askFirstCatalogEntryIds: enabledCatalog + .filter((entry) => askFirstRiskLevels.has(entry.riskLevel)) + .map((entry) => entry.id), + reviewedCatalogEntryIds: + input.activateQuarantined === true + ? enabledCatalog + .filter((entry) => entry.status === "quarantined") + .map((entry) => entry.id) + : undefined, + access, + preserveExistingAccess: deferTaskAccess, + }, + input.actor, + ); + if (!deferTaskAccess && !preserveEmptyInstalls && installs.length === 0) { + const installTargets = + access === "all_agents" + ? [ + { + targetType: "company" as const, + targetId: input.connection.companyId, + }, + ] + : [...new Set(access.agentIds)].map((agentId) => ({ + targetType: "agent" as const, + targetId: agentId, + })); + if (installTargets.length > 0) { + await db + .insert(toolConnectionInstalls) + .values( + installTargets.map((target) => ({ + companyId: input.connection.companyId, + connectionId: input.connection.id, + ...target, + createdByAgentId: + input.actor?.actorType === "agent" + ? (input.actor.actorId ?? null) + : null, + createdByUserId: + input.actor?.actorType === "user" + ? (input.actor.actorId ?? null) + : null, + })), + ) + .onConflictDoNothing(); + } + } + return finished; + } + return finishOAuthCatalogWithRecommendedDefaults; +} +export type OAuthCatalogCompletion = ReturnType; diff --git a/server/src/services/tool-access/oauth/cloud-callback.ts b/server/src/services/tool-access/oauth/cloud-callback.ts new file mode 100644 index 00000000000..5c5a3902620 --- /dev/null +++ b/server/src/services/tool-access/oauth/cloud-callback.ts @@ -0,0 +1,519 @@ +import { managedConnectorProfile } from "./client-settings.js"; +import type { ActorInfo } from "../types.js"; +import { oauthConfig, normalizeOauthScopes } from "./metadata.js"; +import { connectionMethodForConnection } from "../connection-methods.js"; +import { loadGitHubGrantMetadata } from "../github-access-discovery.js"; +import { googleWorkspaceEffectiveScopes, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared"; +import { emitConnectionUpdated } from "../../connector-telemetry.js"; +import { and, eq, gte, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, companyMemberships, principalPermissionGrants, issueThreadInteractions, toolApplications, toolConnections, toolOauthStates } from "@paperclipai/db"; +import type { ConnectionGrantKind, ConnectToolAppResult } from "@paperclipai/shared"; +import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { badRequest, forbidden, HttpError, unprocessable } from "../../../errors.js"; +import { secretService } from "../../secrets.js"; +import { isPaperclipCloudConnectorStrategy, type PaperclipCloudConnector } from "../../paperclip-cloud-connector.js"; + +import type { OAuthStateStore } from "./state.js"; +import type { OAuthCredentialStore } from "./credential-store.js"; +import type { OAuthCatalogCompletion } from "./catalog-completion.js"; +import type { RefreshConnectionCatalog } from "../catalog/contracts.js"; + +import { toApplication } from "../applications/projection.js"; +import { groupedActions } from "../catalog/action-summary.js"; + +type Dependencies = { + now: () => Date; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + state: OAuthStateStore; + refreshCatalog: RefreshConnectionCatalog; + finishOAuthCatalogWithRecommendedDefaults: OAuthCatalogCompletion; + store: Pick; + currentCloudConnector: () => PaperclipCloudConnector | null; +}; + +export function createCloudOAuthCallback(db: Db, dependencies: Dependencies) { + const { now, getConnectionRow, state, store, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, currentCloudConnector } = dependencies; + const { consumeOAuthState, validateOAuthState, rejectPendingOAuthInteraction } = state; + const { createOrRotateOAuthSecret, syncCredentialBindings } = store; + async function completePaperclipCloudConnectorCallback(input: { + state: string; + claimId?: string | null; + error?: string | null; + actor?: ActorInfo; + }): Promise { + // Keep the local state live until the sealed claim is in the durable vault. + // The broker binds repeat claim requests to this stable state value, so a + // transient broker, database, or secret-store failure can retry safely. + const stateRow = await validateOAuthState(input.state, input.actor); + let connection = await getConnectionRow( + stateRow.connectionId, + stateRow.companyId, + ); + const preCloudCallbackLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; + // The connection lifecycle, not the incidental presence of its app profile, + // distinguishes setup from reauthorization. New connections and connections + // revived after removal are drafts until this callback completes. A profile + // may legitimately survive removal because an MCP gateway retains it, or be + // intentionally archived on an otherwise active connection; neither case + // should invert whether recommended defaults are rebuilt. + const shouldFinalizeManagedDefaults = connection.status === "draft"; + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + const method = galleryEntry + ? connectionMethodForConnection(galleryEntry, connection) + : null; + const providerName = galleryEntry?.name ?? "Google Workspace"; + if (input.error) { + const consumedState = await consumeOAuthState(input.state, input.actor); + await rejectPendingOAuthInteraction(consumedState, input.actor); + throw new HttpError( + 400, + `${providerName} authorization did not complete. Start a new ${providerName} connection to try again.`, + { + code: + input.error === "access_denied" + ? "oauth_authorization_denied" + : "paperclip_cloud_connector_failed", + }, + ); + } + if (!input.claimId) + throw badRequest( + `${providerName} callback is missing a claim identifier`, + ); + const cloudConnector = currentCloudConnector(); + if (!cloudConnector) { + throw unprocessable( + `${providerName} connections through Paperclip are not available on this instance yet`, + { + code: "paperclip_cloud_connector_unavailable", + }, + ); + } + const subjectUserId = stateRow.subjectUserId; + const subjectAgentId = stateRow.subjectAgentId; + if ( + !method || + !isPaperclipCloudConnectorStrategy(method.oauthStrategy) || + (!subjectUserId && !subjectAgentId) + ) { + throw badRequest( + "OAuth state does not belong to a managed connector flow", + ); + } + const connectorProfile = method.connectorProfile; + const profile = managedConnectorProfile(connectorProfile); + if (!profile) throw badRequest("Managed connector profile is invalid"); + const connectorSubject = subjectAgentId + ? `agent:${subjectAgentId}` + : subjectUserId!; + const credentials = await cloudConnector.claim({ + subject: connectorSubject, + companyId: stateRow.companyId, + profile: profile.id, + claimId: input.claimId, + redemptionId: input.state, + ...(profile.id === "workspace.all" && stateRow.requestedScopes ? { scopes: stateRow.requestedScopes } : {}), + }); + if (profile.id === "workspace.all") { + const requested = normalizeOauthScopes(stateRow.requestedScopes); + credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); + if (!isGoogleWorkspaceScopeGrant(credentials.scopes)) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); + } + const refreshToken = credentials.refreshToken; + if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { + throw unprocessable( + `${providerName} did not return offline access. Reconnect and grant the requested permissions.`, + { + code: "oauth_refresh_missing", + }, + ); + } + const githubMetadata = + profile.provider === "github" + ? await loadGitHubGrantMetadata( + credentials.accessToken, + fetch, + credentials.appSlug, + ) + : null; + const authorizingUserId = + subjectUserId ?? + (stateRow.createdByActorType === "user" + ? stateRow.createdByActorId + : null); + if (!authorizingUserId) + throw forbidden( + `A signed-in connection manager must authorize ${providerName}`, + ); + + await db.transaction(async (tx) => { + // Keep connector credential persistence serialized with membership + // suspension, downgrade, and removal. A successful claim is only durable + // while the initiating user still holds connection-management authority. + const [membership] = await tx + .select({ + id: companyMemberships.id, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, authorizingUserId), + eq(companyMemberships.status, "active"), + ne(companyMemberships.membershipRole, "viewer"), + ), + ) + .limit(1) + .for("update"); + if (!membership) { + throw forbidden( + `Your company membership no longer permits connection changes. Restore non-viewer access before you connect ${providerName} again.`, + ); + } + if (subjectAgentId) { + const roleCanManage = + membership.membershipRole === "owner" || + membership.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage + ? [] + : await tx + .select({ + id: principalPermissionGrants.id, + }) + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, connection.companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, authorizingUserId), + eq( + principalPermissionGrants.permissionKey, + "tools:manage_connections", + ), + ), + ) + .limit(1) + .for("update"); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden( + "Only a connection manager can authorize a dedicated agent identity.", + ); + } + } + const [consumedState] = await tx + .delete(toolOauthStates) + .where( + and( + eq(toolOauthStates.state, input.state), + gte(toolOauthStates.expiresAt, new Date()), + ), + ) + .returning({ state: toolOauthStates.state }); + if (!consumedState) + throw badRequest( + "OAuth state was not found, expired, or has already been used", + ); + const txSecrets = secretService(tx); + const txSecretContext = { dbClient: tx, secretClient: txSecrets }; + const personalCredential = connection.credentialPolicy === "per_user"; + const agentCredential = connection.credentialPolicy === "per_agent"; + const grantKind: ConnectionGrantKind = agentCredential + ? "agent" + : personalCredential + ? "user" + : "organization"; + const [existingCredentialGrant] = await tx + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, grantKind), + agentCredential + ? eq(connectionGrants.subjectAgentId, subjectAgentId!) + : personalCredential + ? eq(connectionGrants.subjectUserId, subjectUserId!) + : eq(connectionGrants.isDefault, true), + ), + ) + .limit(1) + .for("update"); + await tx.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) + .for("update"); + await tx.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) + .for("update"); + const existingRefs = + existingCredentialGrant?.credentialSecretRefs ?? + (personalCredential || agentCredential + ? [] + : connection.credentialSecretRefs); + const accessRef = await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.access_token", + label: `${providerName} access token`, + value: credentials.accessToken, + actor: input.actor, + existingRefs, + ownerUserId: personalCredential ? subjectUserId! : undefined, + }, + txSecretContext, + ); + const refreshRef = refreshToken + ? await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.refresh_token", + label: `${providerName} refresh token`, + value: refreshToken, + actor: input.actor, + existingRefs, + ownerUserId: personalCredential ? subjectUserId! : undefined, + }, + txSecretContext, + ) + : null; + const credentialSecretRefs = [ + ...existingRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ), + accessRef, + ...(refreshRef ? [refreshRef] : []), + ]; + const grantValues = { + providerTenant: { + name: githubMetadata?.login ?? providerName, + externalId: credentials.subject, + oauth: { + strategy: "paperclip_cloud_connector", + accessTokenExpiresAt: credentials.accessTokenExpiresAt, + scopes: credentials.scopes, + requestedScopes: stateRow.requestedScopes ?? undefined, + tokenType: credentials.tokenType, + ...(credentials.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt } + : {}), + ...(credentials.accessTokenExpiresAt + ? { refreshedAt: now().toISOString() } + : {}), + }, + ...(githubMetadata ? { github: githubMetadata } : {}), + }, + credentialSecretRefs, + status: "active" as const, + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: now(), + }; + if (existingCredentialGrant) { + await tx + .update(connectionGrants) + .set(grantValues) + .where(eq(connectionGrants.id, existingCredentialGrant.id)); + } else { + await tx.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: grantKind, + subjectUserId: personalCredential ? subjectUserId : null, + subjectAgentId: agentCredential ? subjectAgentId : null, + ...grantValues, + isDefault: grantKind === "organization", + createdByUserId: authorizingUserId, + }); + } + const nextConfig = { + ...connection.config, + oauth: { + ...oauthConfig(connection), + strategy: "paperclip_cloud_connector", + provider: galleryEntry?.slug, + connectorProfile, + connectorSubjectUserId: subjectUserId, + connectorSubjectAgentId: subjectAgentId, + resource: method.defaults?.serverUrl, + scopes: [...profile.scopes], + }, + }; + [connection] = await tx + .update(toolConnections) + .set({ + status: shouldFinalizeManagedDefaults ? "draft" : "active", + enabled: shouldFinalizeManagedDefaults ? false : true, + authKind: "oauth", + config: nextConfig, + transportConfig: nextConfig, + credentialRefs: + personalCredential || agentCredential + ? connection.credentialRefs.filter( + (ref) => ref.name !== "oauth.access_token", + ) + : [ + ...connection.credentialRefs.filter( + (ref) => ref.name !== "oauth.access_token", + ), + { + name: "oauth.access_token", + secretId: accessRef.secretId, + version: "latest" as const, + placement: "header" as const, + key: "Authorization", + prefix: "Bearer ", + }, + ], + credentialSecretRefs: + personalCredential || agentCredential + ? connection.credentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ) + : credentialSecretRefs, + updatedAt: now(), + }) + .where(eq(toolConnections.id, connection.id)) + .returning(); + await tx + .update(toolApplications) + .set({ + status: shouldFinalizeManagedDefaults ? "draft" : "active", + updatedAt: now(), + }) + .where(eq(toolApplications.id, connection.applicationId)); + await syncCredentialBindings( + connection, + personalCredential || agentCredential ? credentialSecretRefs : [], + tx, + ); + const linkedInteractionKind = stateRow.interactionId + ? await tx + .select({ kind: issueThreadInteractions.kind }) + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, stateRow.interactionId)) + .limit(1) + .then((rows) => rows[0]?.kind ?? null) + : null; + if ( + stateRow.interactionId && + linkedInteractionKind === "request_confirmation" + ) { + await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: authorizingUserId, + resolvedAt: now(), + updatedAt: now(), + }) + .where(eq(issueThreadInteractions.id, stateRow.interactionId)); + } + }); + // The transaction above committed the connection's lifecycle write; a + // Paperclip Cloud connector callback is the managed variant of an OAuth + // callback completion. + emitConnectionUpdated(connection, preCloudCallbackLifecycle, "oauth_callback"); + if (githubMetadata) { + const [githubGrant] = await db + .select({ id: connectionGrants.id }) + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + subjectAgentId + ? and( + eq(connectionGrants.kind, "agent"), + eq(connectionGrants.subjectAgentId, subjectAgentId), + ) + : and( + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, subjectUserId!), + ), + ), + ) + .limit(1); + if (!githubGrant) throw new Error("GitHub grant was not persisted"); + await Promise.all( + githubMetadata.installationIds.map((installationId) => + cloudConnector.setWebhookBinding({ + subject: connectorSubject, + companyId: connection.companyId, + id: `${githubGrant.id}_${installationId}`, + installationId, + connectionId: connection.id, + grantId: githubGrant.id, + active: true, + accessToken: credentials.accessToken, + }), + ), + ); + } + const refresh = await refreshCatalog(connection.id, input.actor, { + enableAllByDefault: false, + quarantineManagedOAuthDraft: shouldFinalizeManagedDefaults, + skipDefaultProfileSync: true, + credentialHeaders: { Authorization: `Bearer ${credentials.accessToken}` }, + }); + const recommended = recommendedDefaultsForApp(galleryEntry!, method.key); + const suggestedDefaults = subjectAgentId + ? { ...recommended, access: { agentIds: [subjectAgentId] } as const } + : recommended; + const finished = shouldFinalizeManagedDefaults + ? await finishOAuthCatalogWithRecommendedDefaults({ + interactionId: stateRow.interactionId, + connection, + catalog: refresh.catalog, + suggestedDefaults, + activateQuarantined: true, + actor: input.actor, + }) + : null; + const activatedCatalogEntryIds = new Set( + shouldFinalizeManagedDefaults + ? refresh.catalog + .filter((entry) => entry.status === "quarantined") + .map((entry) => entry.id) + : [], + ); + const catalog = refresh.catalog.map((entry) => + activatedCatalogEntryIds.has(entry.id) + ? { ...entry, status: "active" as const } + : entry, + ); + const [application] = await db + .select() + .from(toolApplications) + .where(eq(toolApplications.id, connection.applicationId)); + return { + connectionId: connection.id, + application: toApplication(application), + connection: finished?.connection ?? refresh.connection, + catalog, + actions: groupedActions(catalog), + suggestedDefaults, + auth: null, + }; + } + return completePaperclipCloudConnectorCallback; +} diff --git a/server/src/services/tool-access/oauth/direct-callback.ts b/server/src/services/tool-access/oauth/direct-callback.ts new file mode 100644 index 00000000000..a95f49c8a21 --- /dev/null +++ b/server/src/services/tool-access/oauth/direct-callback.ts @@ -0,0 +1,700 @@ +import { credentialScope } from "./client-settings.js"; +import { withoutOAuthRefreshLease } from "./refresh-leases.js"; +import type { ActorInfo } from "../types.js"; +import { createOAuthDiscovery } from "./discovery.js"; +import { createOAuthClientProtocol, storedOAuthTokenEndpointAuthMethod } from "./client.js"; +import { normalizeOAuthProviderError, oauthProviderErrorMessage } from "./provider-errors.js"; +import { oauthConfig, normalizeOauthScopes, resolveGrantedOauthScopes } from "./metadata.js"; +import { connectionMethodForConnection } from "../connection-methods.js"; +import { googleWorkspaceEffectiveScopes, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared"; +import { emitConnectionUpdated } from "../../connector-telemetry.js"; +import { and, eq, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, companyMemberships, principalPermissionGrants, issueThreadInteractions, toolApplications, toolConnections } from "@paperclipai/db"; +import type { ConnectToolAppResult } from "@paperclipai/shared"; +import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { badRequest, forbidden, HttpError, unprocessable } from "../../../errors.js"; +import { secretService } from "../../secrets.js"; + +import type { OAuthStateStore } from "./state.js"; +import type { OAuthCredentialStore } from "./credential-store.js"; +import type { OAuthCatalogCompletion } from "./catalog-completion.js"; +import type { RefreshConnectionCatalog, CheckConnectionHealth } from "../catalog/contracts.js"; +import type { ConnectionSetupPolicy } from "../connections/setup-policy.js"; + +import { toApplication } from "../applications/projection.js"; +import { groupedActions } from "../catalog/action-summary.js"; + +import { assertOAuthRedirectConstraints, assertOAuthCallbackIssuer } from "./authorization-constraints.js"; +import type { createOrganizationGrantStore } from "../connections/organization-grant.js"; +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) + return value as Record; + return {}; +} + +type Dependencies = { + now: () => Date; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + state: OAuthStateStore; + refreshCatalog: RefreshConnectionCatalog; + finishOAuthCatalogWithRecommendedDefaults: OAuthCatalogCompletion; + store: Pick; + setupPolicy: ConnectionSetupPolicy; + checkConnectionHealth: CheckConnectionHealth; + ensureDefaultOrganizationGrant: ReturnType; + oauthEndpointsForConnection: ReturnType["oauthEndpointsForConnection"]; + exchangeOAuthToken: ReturnType["exchangeOAuthToken"]; +}; + +export function createDirectOAuthCallback(db: Db, dependencies: Dependencies) { + const { now, getConnectionRow, state, store, setupPolicy, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, checkConnectionHealth, ensureDefaultOrganizationGrant, oauthEndpointsForConnection, exchangeOAuthToken } = dependencies; + const { consumeOAuthState, rejectPendingOAuthInteraction } = state; + const { createOrRotateOAuthSecret, oauthClientForConnection, syncCredentialBindings } = store; + const { assertConnectorConnectionSetupEnabled } = setupPolicy; + async function completeOAuthCallback(input: { + state: string; + code?: string | null; + /** + * RFC 6749 `error`. Untrusted, and deliberately the *only* thing read from a + * failed callback — `error_description` and `error_uri` are not accepted as + * input at all, so there is nothing for a hostile provider to reflect + * through (PAP-17108). + */ + error?: string | null; + redirectUri: string; + /** RFC 9207 `iss`, when the authorization server returns it. */ + iss?: string | null; + actor?: ActorInfo; + }): Promise { + // Binding first, outcome second: the provider's report of a failure is only + // acted on once the callback is bound to a state Paperclip issued and to the + // actor that started the flow, so an unsolicited callback cannot drive any + // path here. Consuming the state up front is what makes a denial terminal — + // a refused request must not stay completable by a later code (PAP-17109). + const stateRow = await consumeOAuthState(input.state, input.actor); + if (input.error) { + await rejectPendingOAuthInteraction(stateRow, input.actor); + const providerError = normalizeOAuthProviderError(input.error); + throw new HttpError( + 400, + oauthProviderErrorMessage( + providerError, + "The authorization server denied the request.", + ), + { + code: "oauth_authorization_denied", + providerError, + }, + ); + } + // Neither a code nor an error is not a usable answer either. It still spends + // the request: the recovery is a fresh authorization, not a state left live + // waiting for a better callback. + if (!input.code) throw badRequest("OAuth callback is missing a code"); + + let connection = await getConnectionRow( + stateRow.connectionId, + stateRow.companyId, + ); + // Reauthorization refreshes credentials and catalog without rebuilding the + // operator's action profile, policy rules, or access bindings. + const shouldFinalizeDefaults = connection.status === "draft"; + const preCallbackLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; + await assertConnectorConnectionSetupEnabled(connection); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + assertOAuthRedirectConstraints(galleryEntry, input.redirectUri); + const endpoints = await oauthEndpointsForConnection( + connection, + null, + input.redirectUri, + ); + assertOAuthCallbackIssuer(connection, endpoints, input.iss); + // Generic states created before requested scopes were persisted may still + // complete after an upgrade. Keep their original cached/discovered scope set. + const authorizedScopes = normalizeOauthScopes( + stateRow.requestedScopes ?? (galleryEntry ? [] : endpoints.scopes), + ); + const client = await oauthClientForConnection( + connection, + endpoints.provider, + input.actor, + ); + if (!client.clientId) + throw unprocessable( + `OAuth client id is not configured for ${endpoints.provider}`, + ); + + const token = await exchangeOAuthToken({ + tokenUrl: endpoints.tokenUrl, + clientId: client.clientId, + clientSecret: client.clientSecret, + tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod( + oauthConfig(connection), + client.clientSecret, + ), + redirectUri: input.redirectUri, + codeVerifier: stateRow.codeVerifier, + code: input.code, + resource: endpoints.resource, + }); + if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { + throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" }); + } + if (sourceTemplateKey === "google-workspace") { + token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" "); + if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); + } + const connectedAt = now(); + const expiresAt = token.expiresIn + ? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString() + : null; + if (stateRow.subjectUserId) { + let personalCredentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs = + []; + await db.transaction(async (tx) => { + // Serialize callback persistence with suspension/removal. Those paths + // lock this same membership row before sweeping personal credentials. + const [membership] = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, stateRow.subjectUserId!), + eq(companyMemberships.status, "active"), + ne(companyMemberships.membershipRole, "viewer"), + ), + ) + .limit(1) + .for("update"); + if (!membership) { + throw forbidden( + "Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.", + ); + } + const txSecrets = secretService(tx); + const txSecretContext = { dbClient: tx, secretClient: txSecrets }; + + const [existingUserGrant] = await tx + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, stateRow.subjectUserId!), + ), + ) + .limit(1) + .for("update"); + await tx.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) + .for("update"); + await tx.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) + .for("update"); + const subjectCredentialSecretRefs = + existingUserGrant?.credentialSecretRefs ?? []; + const accessRef = await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.access_token", + label: "OAuth access token", + value: token.accessToken, + actor: input.actor, + existingRefs: subjectCredentialSecretRefs, + ownerUserId: stateRow.subjectUserId!, + }, + txSecretContext, + ); + const nextCredentialSecretRefs = [ + ...subjectCredentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ), + accessRef, + ]; + if (token.refreshToken) { + nextCredentialSecretRefs.push( + await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.refresh_token", + label: "OAuth refresh token", + value: token.refreshToken, + actor: input.actor, + existingRefs: subjectCredentialSecretRefs, + ownerUserId: stateRow.subjectUserId!, + }, + txSecretContext, + ), + ); + } else { + const existingRefreshRef = subjectCredentialSecretRefs.find( + (ref) => ref.configPath === "oauth.refresh_token", + ); + if (existingRefreshRef) + nextCredentialSecretRefs.push(existingRefreshRef); + } + + const grantedScopes = resolveGrantedOauthScopes({ + tokenScope: token.scope, + requestedScopes: authorizedScopes, + }); + const grantValues = { + providerTenant: { + ...(existingUserGrant?.providerTenant ?? {}), + oauth: { + ...asRecord(asRecord(existingUserGrant?.providerTenant).oauth), + strategy: "direct_oauth", + accessTokenExpiresAt: expiresAt ?? undefined, + scopes: grantedScopes.scopes, + scopeSource: grantedScopes.scopeSource, + unrequestedScopes: grantedScopes.unrequestedScopes, + // Per-grant, because two users can authorize the same connection with + // different scopes. The connection-level list is whichever callback ran last, + // so it is the wrong baseline for anyone else's refresh. + requestedScopes: authorizedScopes, + tokenType: token.tokenType, + refreshedAt: connectedAt.toISOString(), + }, + }, + credentialSecretRefs: nextCredentialSecretRefs, + status: "active" as const, + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: new Date(), + }; + if (existingUserGrant) { + await tx + .update(connectionGrants) + .set(grantValues) + .where(eq(connectionGrants.id, existingUserGrant.id)); + } else { + await tx.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: "user", + subjectUserId: stateRow.subjectUserId!, + ...grantValues, + isDefault: false, + createdByUserId: stateRow.subjectUserId!, + }); + } + personalCredentialSecretRefs = nextCredentialSecretRefs; + const nextConfig = { + ...connection.config, + oauth: { + ...withoutOAuthRefreshLease(oauthConfig(connection)), + provider: endpoints.provider, + authorizationUrl: endpoints.authorizationUrl, + tokenUrl: endpoints.tokenUrl, + metadataUrl: endpoints.metadataUrl ?? null, + scopes: authorizedScopes, + clientIdEnv: client.clientIdEnv, + clientSecretEnv: client.clientSecret + ? client.clientSecretEnv + : null, + credentialScope: credentialScope(connection, input.actor), + issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, + resource: + endpoints.resource ?? oauthConfig(connection).resource ?? null, + expiresAt, + scope: token.scope, + tokenType: token.tokenType, + connectedAt: connectedAt.toISOString(), + }, + providerMetadata: { + ...asRecord(connection.config.providerMetadata), + oauth: { + expiresAt, + scope: token.scope, + scopeSource: grantedScopes.scopeSource, + unrequestedScopes: grantedScopes.unrequestedScopes, + tokenType: token.tokenType, + }, + }, + }; + const [updatedConnection] = await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + authKind: "oauth", + credentialPolicy: connection.credentialPolicy, + config: nextConfig, + transportConfig: nextConfig, + // A personal-only connection keeps tokens exclusively on its user + // grant. Adding a personal identity to an existing shared/fallback + // connection must not erase that connection's organization token. + credentialRefs: + connection.credentialPolicy === "per_user" + ? connection.credentialRefs.filter( + (ref) => ref.name !== "oauth.access_token", + ) + : connection.credentialRefs, + credentialSecretRefs: + connection.credentialPolicy === "per_user" + ? connection.credentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ) + : connection.credentialSecretRefs, + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, connection.id)) + .returning(); + if (!updatedConnection) + throw new Error("OAuth connection was not found"); + connection = updatedConnection; + await tx + .update(toolApplications) + .set({ + status: "active", + updatedAt: new Date(), + }) + .where(eq(toolApplications.id, connection.applicationId)); + const linkedInteractionKind = stateRow.interactionId + ? await tx + .select({ kind: issueThreadInteractions.kind }) + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, stateRow.interactionId)) + .limit(1) + .then((rows) => rows[0]?.kind ?? null) + : null; + if ( + stateRow.interactionId && + linkedInteractionKind === "request_confirmation" + ) { + await tx + .update(issueThreadInteractions) + .set({ + status: "accepted", + result: { version: 1, outcome: "accepted" }, + resolvedByUserId: stateRow.subjectUserId!, + resolvedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(issueThreadInteractions.id, stateRow.interactionId), + eq(issueThreadInteractions.companyId, connection.companyId), + ), + ); + } + await syncCredentialBindings( + connection, + connection.credentialPolicy === "per_user" + ? personalCredentialSecretRefs + : [], + tx, + ); + }); + emitConnectionUpdated( + connection, + preCallbackLifecycle, + "oauth_callback", + ); + + // Personal OAuth used to return immediately after saving the grant. That + // left the connection draft/paused and its catalog empty, so the person + // who had just consented landed on a false "Nothing to test" state. + // Activate and discover with the just-issued token before returning. + const refresh = await refreshCatalog(connection.id, input.actor, { + enableAllByDefault: shouldFinalizeDefaults, + skipDefaultProfileSync: shouldFinalizeDefaults, + credentialHeaders: { Authorization: `Bearer ${token.accessToken}` }, + }); + const [application] = await db + .select() + .from(toolApplications) + .where(eq(toolApplications.id, connection.applicationId)); + if (!application) + throw new Error("OAuth connection application was not found"); + const suggestedDefaults = galleryEntry + ? recommendedDefaultsForApp( + galleryEntry, + connectionMethodForConnection(galleryEntry, connection).key, + ) + : { access: "all_agents" as const, askFirstRiskLevels: [] }; + const finished = shouldFinalizeDefaults + ? await finishOAuthCatalogWithRecommendedDefaults({ + interactionId: stateRow.interactionId, + connection, + catalog: refresh.catalog, + suggestedDefaults, + actor: input.actor, + }) + : null; + return { + connectionId: refresh.connection.id, + application: toApplication(application), + connection: finished?.connection ?? refresh.connection, + catalog: refresh.catalog, + actions: groupedActions(refresh.catalog), + suggestedDefaults, + auth: null, + }; + } + + const organizationActorUserId = + stateRow.createdByActorType === "user" ? stateRow.createdByActorId : null; + if (!organizationActorUserId) { + throw forbidden( + "Organization OAuth completion requires the user who started sign-in", + ); + } + await db.transaction(async (tx) => { + // Keep callback persistence serialized with membership suspension, role + // downgrade, and removal. Once this row is locked, authority cannot be + // revoked between the live check and the shared credential/grant writes. + const [membership] = await tx + .select({ + id: companyMemberships.id, + membershipRole: companyMemberships.membershipRole, + }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, organizationActorUserId), + eq(companyMemberships.status, "active"), + ne(companyMemberships.membershipRole, "viewer"), + ), + ) + .limit(1) + .for("update"); + if (!membership) { + throw forbidden( + "Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.", + ); + } + const roleCanManage = + membership.membershipRole === "owner" || + membership.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage + ? [] + : await tx + .select({ + id: principalPermissionGrants.id, + }) + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, connection.companyId), + eq(principalPermissionGrants.principalType, "user"), + eq( + principalPermissionGrants.principalId, + organizationActorUserId, + ), + eq( + principalPermissionGrants.permissionKey, + "tools:manage_connections", + ), + ), + ) + .limit(1) + .for("update"); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden( + "Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.", + ); + } + await tx.select({ id: connectionGrants.id }).from(connectionGrants) + .where(and(eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true))) + .for("update"); + await tx.select({ id: toolApplications.id }).from(toolApplications) + .where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId))) + .for("update"); + await tx.select({ id: toolConnections.id }).from(toolConnections) + .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))) + .for("update"); + const txSecrets = secretService(tx); + const txSecretContext = { dbClient: tx, secretClient: txSecrets }; + + const subjectCredentialSecretRefs = connection.credentialSecretRefs; + const accessRef = await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.access_token", + label: "OAuth access token", + value: token.accessToken, + actor: input.actor, + }, + txSecretContext, + ); + const nextCredentialSecretRefs = [ + ...subjectCredentialSecretRefs.filter( + (ref) => + ref.configPath !== "oauth.access_token" && + ref.configPath !== "oauth.refresh_token", + ), + accessRef, + ]; + if (token.refreshToken) { + nextCredentialSecretRefs.push( + await createOrRotateOAuthSecret( + { + companyId: connection.companyId, + connection, + configPath: "oauth.refresh_token", + label: "OAuth refresh token", + value: token.refreshToken, + actor: input.actor, + }, + txSecretContext, + ), + ); + } else { + const existingRefreshRef = subjectCredentialSecretRefs.find( + (ref) => ref.configPath === "oauth.refresh_token", + ); + if (existingRefreshRef) + nextCredentialSecretRefs.push(existingRefreshRef); + } + const organizationGrantedScopes = resolveGrantedOauthScopes({ + tokenScope: token.scope, + requestedScopes: authorizedScopes, + }); + const nextConfig = { + ...connection.config, + oauth: { + ...withoutOAuthRefreshLease(oauthConfig(connection)), + provider: endpoints.provider, + authorizationUrl: endpoints.authorizationUrl, + tokenUrl: endpoints.tokenUrl, + metadataUrl: endpoints.metadataUrl ?? null, + scopes: authorizedScopes, + clientIdEnv: client.clientIdEnv, + clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null, + credentialScope: credentialScope(connection, input.actor), + // Keep the issuer and resource this grant was minted against so refresh, + // reconnect, revoke and diagnostics resolve the same authorization server + // instead of re-discovering one from a possibly-changed endpoint. + issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null, + resource: + endpoints.resource ?? oauthConfig(connection).resource ?? null, + expiresAt, + scope: token.scope, + tokenType: token.tokenType, + connectedAt: connectedAt.toISOString(), + }, + providerMetadata: { + ...asRecord(connection.config.providerMetadata), + oauth: { + expiresAt, + scope: token.scope, + scopeSource: organizationGrantedScopes.scopeSource, + unrequestedScopes: organizationGrantedScopes.unrequestedScopes, + tokenType: token.tokenType, + }, + }, + }; + const [updatedConnection] = await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + authKind: "oauth", + config: nextConfig, + transportConfig: nextConfig, + credentialSecretRefs: nextCredentialSecretRefs, + credentialRefs: [ + ...connection.credentialRefs.filter( + (ref) => ref.name !== "oauth.access_token", + ), + { + name: "oauth.access_token", + secretId: accessRef.secretId, + version: "latest" as const, + placement: "header" as const, + key: "Authorization", + prefix: "Bearer ", + }, + ], + updatedAt: new Date(), + }) + .where(eq(toolConnections.id, connection.id)) + .returning(); + connection = updatedConnection; + await tx + .update(toolApplications) + .set({ status: "active", updatedAt: new Date() }) + .where(eq(toolApplications.id, connection.applicationId)); + // The organization grant is created before OAuth has any secrets to attach. + // Synchronize it after every successful callback/rotation so all real tool + // execution paths receive the credentials that setup and catalog discovery + // just proved. + await ensureDefaultOrganizationGrant(connection, tx, undefined, { + scopes: organizationGrantedScopes.scopes, + scopeSource: organizationGrantedScopes.scopeSource, + unrequestedScopes: organizationGrantedScopes.unrequestedScopes, + requestedScopes: authorizedScopes, + }); + await syncCredentialBindings(connection, [], tx); + }); + emitConnectionUpdated( + connection, + preCallbackLifecycle, + "oauth_callback", + ); + + await checkConnectionHealth(connection.id, input.actor); + const refresh = await refreshCatalog(connection.id, input.actor, { + enableAllByDefault: shouldFinalizeDefaults, + skipDefaultProfileSync: shouldFinalizeDefaults, + }); + const [application] = await db + .select() + .from(toolApplications) + .where(eq(toolApplications.id, connection.applicationId)); + const suggestedDefaults = galleryEntry + ? recommendedDefaultsForApp( + galleryEntry, + connectionMethodForConnection(galleryEntry, connection).key, + ) + : { + access: "all_agents" as const, + askFirstRiskLevels: [], + }; + const finished = shouldFinalizeDefaults + ? await finishOAuthCatalogWithRecommendedDefaults({ + interactionId: stateRow.interactionId, + connection, + catalog: refresh.catalog, + suggestedDefaults, + actor: input.actor, + }) + : null; + return { + connectionId: refresh.connection.id, + application: toApplication(application), + connection: finished?.connection ?? refresh.connection, + catalog: refresh.catalog, + actions: groupedActions(refresh.catalog), + suggestedDefaults, + auth: null, + }; + } + return completeOAuthCallback; +} diff --git a/server/src/services/tool-access/oauth/state.ts b/server/src/services/tool-access/oauth/state.ts new file mode 100644 index 00000000000..2ab81656edc --- /dev/null +++ b/server/src/services/tool-access/oauth/state.ts @@ -0,0 +1,188 @@ +import type { ActorInfo } from "../types.js"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { issueThreadInteractions, toolOauthStates } from "@paperclipai/db"; +import { connectionIntentPayloadSchema } from "@paperclipai/shared"; +import { badRequest, forbidden } from "../../../errors.js"; + +import { actorBinding } from "../actor.js"; +function oauthActorType(value: string | null): ActorInfo["actorType"] | null { + return value === "agent" || + value === "user" || + value === "system" || + value === "plugin" + ? value + : null; +} + +function assertSameOAuthActor( + stateRow: typeof toolOauthStates.$inferSelect, + actor: ActorInfo | undefined, +) { + const expected = { + actorType: oauthActorType(stateRow.createdByActorType), + actorId: stateRow.createdByActorId, + sessionId: stateRow.createdBySessionId, + }; + const actual = actorBinding(actor); + if (!expected.actorType || !expected.actorId) { + throw forbidden( + "OAuth sign-in state is not bound to an authenticated board session", + ); + } + if ( + expected.actorType !== actual.actorType || + expected.actorId !== actual.actorId + ) { + throw forbidden( + "OAuth sign-in must be completed by the user who started it", + ); + } + if (expected.sessionId && expected.sessionId !== actual.sessionId) { + throw forbidden( + "OAuth sign-in must be completed from the same authenticated session", + ); + } +} + +export function createOAuthStateStore(db: Db) { + async function peekOAuthState(state: string) { + const [row] = await db + .select({ + companyId: toolOauthStates.companyId, + connectionId: toolOauthStates.connectionId, + subjectUserId: toolOauthStates.subjectUserId, + subjectAgentId: toolOauthStates.subjectAgentId, + returnTo: toolOauthStates.returnTo, + issueId: toolOauthStates.issueId, + interactionId: toolOauthStates.interactionId, + }) + .from(toolOauthStates) + .where(eq(toolOauthStates.state, state)) + .limit(1); + return row ?? null; + } + + /** Bind a callback to its initiating actor without consuming retryable state. */ +async function validateOAuthState( + state: string, + actor: ActorInfo | undefined, + ) { + const [stateRow] = await db + .select() + .from(toolOauthStates) + .where(eq(toolOauthStates.state, state)) + .limit(1); + if (!stateRow) + throw badRequest("OAuth state was not found or has already been used"); + if (stateRow.expiresAt.getTime() <= Date.now()) + throw badRequest("OAuth state has expired"); + if (stateRow.subjectUserId) { + if ( + actor?.actorType !== "user" || + actor.actorId !== stateRow.subjectUserId + ) { + throw forbidden( + "OAuth callback user does not match the requested subject", + ); + } + } else { + assertSameOAuthActor(stateRow, actor); + } + return stateRow; + } + + /** + * Answer a terminal authorization callback exactly once. Actor validation + * happens before the atomic delete, so an unbound callback cannot consume a + * valid flow and concurrent callbacks cannot both complete it. + */ +async function consumeOAuthState( + state: string, + actor: ActorInfo | undefined, + ) { + const stateRow = await validateOAuthState(state, actor); + const [consumed] = await db + .delete(toolOauthStates) + .where(eq(toolOauthStates.state, state)) + .returning(); + if (!consumed) + throw badRequest("OAuth state was not found or has already been used"); + return consumed; + } + + /** + * End the board's "Connect your account" prompt when the user declines in the + * provider's window (PAP-17109). Without this the card stays `pending`, so the + * board keeps offering an authorization link for a flow the user just refused + * and the requesting agent never learns the answer. + * + * Scoped to a still-`pending` row: a board user who already answered the card + * directly keeps their own answer. + */ +async function rejectPendingOAuthInteraction( + stateRow: typeof toolOauthStates.$inferSelect, + actor: ActorInfo | undefined, + ) { + if (!stateRow.interactionId) return; + const now = new Date(); + const linked = await db + .select({ + kind: issueThreadInteractions.kind, + payload: issueThreadInteractions.payload, + }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.id, stateRow.interactionId), + eq(issueThreadInteractions.companyId, stateRow.companyId), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (linked?.kind === "connection_intent") { + const connectionIntentPayload = connectionIntentPayloadSchema.parse( + linked.payload, + ); + await db + .update(issueThreadInteractions) + .set({ + payload: { ...connectionIntentPayload, phase: "needs_retry" }, + updatedAt: now, + }) + .where( + and( + eq(issueThreadInteractions.id, stateRow.interactionId), + eq(issueThreadInteractions.companyId, stateRow.companyId), + eq(issueThreadInteractions.status, "pending"), + ), + ); + return; + } + await db + .update(issueThreadInteractions) + .set({ + status: "rejected", + result: { + version: 1, + outcome: "rejected", + // Paperclip's own words: the provider's explanation is untrusted and + // this reason is rendered in the thread (PAP-17108). + reason: + "Authorization was declined or cancelled in the provider's window", + }, + resolvedByUserId: actor?.actorType === "user" ? actor.actorId : null, + resolvedAt: now, + updatedAt: now, + }) + .where( + and( + eq(issueThreadInteractions.id, stateRow.interactionId), + eq(issueThreadInteractions.companyId, stateRow.companyId), + eq(issueThreadInteractions.status, "pending"), + ), + ); + } + return { peekOAuthState, validateOAuthState, consumeOAuthState, rejectPendingOAuthInteraction }; +} +export type OAuthStateStore = ReturnType; diff --git a/server/src/services/tool-access/oauth/vercel-callback.ts b/server/src/services/tool-access/oauth/vercel-callback.ts new file mode 100644 index 00000000000..87164abd425 --- /dev/null +++ b/server/src/services/tool-access/oauth/vercel-callback.ts @@ -0,0 +1,257 @@ +import { vercelConnectHttpError, vercelCredentialFor, vercelConnectResourcesFor } from "../vercel-credentials.js"; +import type { ActorInfo } from "../types.js"; +import { connectionMethodForConnection, projectedConnectionHeaders } from "../connection-methods.js"; +import { emitConnectionUpdated } from "../../connector-telemetry.js"; +import { and, eq, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { connectionGrants, companyMemberships, toolApplications, toolConnections } from "@paperclipai/db"; +import type { ConnectionGrantKind, ConnectToolAppResult } from "@paperclipai/shared"; +import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared"; +import { badRequest, forbidden, HttpError } from "../../../errors.js"; +import { deriveVercelConnectSubject, vercelGrantReference, vercelTokenRequest, VercelConnectClientError, type VercelConnectClient } from "../../vercel-connect.js"; + +import type { OAuthStateStore } from "./state.js"; + +import type { OAuthCatalogCompletion } from "./catalog-completion.js"; +import type { RefreshConnectionCatalog } from "../catalog/contracts.js"; + +import { toApplication } from "../applications/projection.js"; +import { groupedActions } from "../catalog/action-summary.js"; + +type Dependencies = { + now: () => Date; + getConnectionRow(idOrUid: string, companyId?: string): Promise; + state: OAuthStateStore; + refreshCatalog: RefreshConnectionCatalog; + finishOAuthCatalogWithRecommendedDefaults: OAuthCatalogCompletion; + vercelConnect: VercelConnectClient | null; +}; + +export function createVercelOAuthCallback(db: Db, dependencies: Dependencies) { + const { now, getConnectionRow, state, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, vercelConnect } = dependencies; + const { consumeOAuthState, rejectPendingOAuthInteraction } = state; + async function completeVercelConnectCallback(input: { + state: string; + error?: string | null; + actor?: ActorInfo; + }): Promise { + const stateRow = await consumeOAuthState(input.state, input.actor); + if (stateRow.codeVerifier !== "vercel-connect") { + throw badRequest("OAuth state does not belong to a Vercel Connect flow"); + } + if (input.error) { + await rejectPendingOAuthInteraction(stateRow, input.actor); + throw new HttpError( + 400, + "Vercel Connect authorization did not complete. Start a new authorization to try again.", + { + code: + input.error === "access_denied" + ? "oauth_authorization_denied" + : "vercel_connect_authorization_required", + }, + ); + } + if (!vercelConnect) + throw vercelConnectHttpError( + new VercelConnectClientError("vercel_connect_unavailable", 503), + ); + let connection = await getConnectionRow( + stateRow.connectionId, + stateRow.companyId, + ); + const credential = vercelCredentialFor(connection); + if ( + credential.principalMode !== "user" || + connection.authKind !== "oauth" + ) { + throw badRequest( + "Vercel Connect callback does not match this connection", + ); + } + const grantKind: ConnectionGrantKind = stateRow.subjectUserId + ? "user" + : "organization"; + if (stateRow.subjectUserId) { + const [membership] = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, connection.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, stateRow.subjectUserId), + eq(companyMemberships.status, "active"), + ne(companyMemberships.membershipRole, "viewer"), + ), + ) + .limit(1); + if (!membership) { + throw forbidden( + "Your company membership no longer permits connection changes. Restore non-viewer access before authorizing this connection.", + ); + } + } + const [existingGrant] = await db + .select() + .from(connectionGrants) + .where( + and( + eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, grantKind), + grantKind === "user" + ? eq(connectionGrants.subjectUserId, stateRow.subjectUserId!) + : eq(connectionGrants.isDefault, true), + ), + ) + .limit(1); + const derived = deriveVercelConnectSubject({ + credential, + connectionId: connection.id, + companyId: connection.companyId, + grantKind, + subjectUserId: stateRow.subjectUserId, + }); + const request = vercelTokenRequest({ + credential, + connectionId: connection.id, + companyId: connection.companyId, + resources: vercelConnectResourcesFor(connection), + grant: { + kind: grantKind, + subjectUserId: stateRow.subjectUserId, + externalCredential: existingGrant?.externalCredential, + }, + }); + let token; + try { + token = await vercelConnect.getToken(request, { forceRefresh: true }); + } catch (error) { + throw vercelConnectHttpError(error); + } + if ( + token.connector.id !== credential.connectorId && + token.connector.uid !== credential.connectorUid + ) { + throw new HttpError( + 502, + "Vercel Connect returned a token for a different connector.", + { + code: "vercel_connect_connector_mismatch", + }, + ); + } + const externalGrant = vercelGrantReference({ + credential, + token, + subjectId: derived.subjectId, + verifiedAt: now(), + }); + const grantValues = { + providerTenant: token.tenantId + ? { name: credential.service, externalId: token.tenantId } + : { name: credential.service }, + credentialSecretRefs: [], + externalCredential: externalGrant, + status: "active" as const, + isDefault: grantKind === "organization", + revokedAt: null, + revokedByAgentId: null, + revokedByUserId: null, + updatedAt: now(), + }; + if (existingGrant) { + await db + .update(connectionGrants) + .set(grantValues) + .where(eq(connectionGrants.id, existingGrant.id)); + } else { + await db.insert(connectionGrants).values({ + companyId: connection.companyId, + connectionId: connection.id, + kind: grantKind, + subjectUserId: stateRow.subjectUserId, + ...grantValues, + createdByUserId: + input.actor?.actorType === "user" + ? (input.actor.actorId ?? null) + : null, + }); + } + const preActivationLifecycle = { + status: connection.status, + enabled: connection.enabled, + }; + [connection] = await db + .update(toolConnections) + .set({ + status: "active", + enabled: true, + credentialRefs: [], + credentialSecretRefs: [], + updatedAt: now(), + }) + .where( + and( + eq(toolConnections.id, connection.id), + eq(toolConnections.companyId, connection.companyId), + ), + ) + .returning(); + emitConnectionUpdated( + connection, + preActivationLifecycle, + "oauth_callback", + ); + await db + .update(toolApplications) + .set({ status: "active", updatedAt: now() }) + .where(eq(toolApplications.id, connection.applicationId)); + const sourceTemplateKey = + typeof connection.config.sourceTemplateKey === "string" + ? connection.config.sourceTemplateKey + : null; + const galleryEntry = sourceTemplateKey + ? getConnectableAppDefinition(sourceTemplateKey) + : null; + if (!galleryEntry) + throw badRequest( + "Vercel Connect connection is missing its reviewed app definition", + ); + const method = connectionMethodForConnection(galleryEntry, connection); + const refresh = await refreshCatalog(connection.id, input.actor, { + enableAllByDefault: true, + skipDefaultProfileSync: true, + credentialHeaders: { + ...projectedConnectionHeaders(connection), + [credential.headerName]: `${credential.headerPrefix ?? ""}${token.token}`, + }, + }); + const suggestedDefaults = recommendedDefaultsForApp( + galleryEntry, + method.key, + ); + const finished = await finishOAuthCatalogWithRecommendedDefaults({ + interactionId: stateRow.interactionId, + connection, + catalog: refresh.catalog, + suggestedDefaults, + actor: input.actor, + }); + const [application] = await db + .select() + .from(toolApplications) + .where(eq(toolApplications.id, connection.applicationId)); + return { + connectionId: connection.id, + application: toApplication(application), + connection: finished.connection, + catalog: refresh.catalog, + actions: groupedActions(refresh.catalog), + suggestedDefaults, + auth: null, + }; + } + return completeVercelConnectCallback; +}