diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 3540a3baa8..1f52c38ad9 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -93,6 +93,78 @@ describe("Paperclip Cloud connector", () => { }); }); + it("prefers a validated HTTPS provider URL over the legacy confirmation URL", async () => { + const keys = config(); + const connector = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => Response.json({ + confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state", + expiresAt: "2099-08-21T20:00:00.000Z", + })) as typeof fetch, + }); + + await expect(connector.startAuthorization({ + subject, + companyId, + profile: "github.code", + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", + returnState: "state-direct", + })).resolves.toMatchObject({ + authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state", + }); + }); + + it("accepts the fixed Google authorization endpoint for Google profiles", async () => { + const keys = config(); + const connector = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => Response.json({ + confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state", + expiresAt: "2099-08-21T20:00:00.000Z", + })) as typeof fetch, + }); + + await expect(connector.startAuthorization({ + subject, + companyId, + profile: "gmail.draft", + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", + returnState: "state-direct-google", + })).resolves.toMatchObject({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?client_id=client&state=broker-state", + }); + }); + + it.each([ + ["non-string", { href: "https://github.com/login/oauth/authorize" }], + ["plaintext HTTP", "http://github.com/login/oauth/authorize"], + ["embedded credentials", "https://user:password@github.com/login/oauth/authorize"], + ["fragment", "https://github.com/login/oauth/authorize#unexpected"], + ["unapproved HTTPS origin", "https://attacker.example.test/login/oauth/authorize"], + ["unapproved provider path", "https://github.com/session/authorize"], + ["not a URL", "not-a-url"], + ])("rejects a malformed direct provider URL: %s", async (_label, authorizationUrl) => { + const keys = config(); + const connector = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => Response.json({ + confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + authorizationUrl, + expiresAt: "2099-08-21T20:00:00.000Z", + })) as typeof fetch, + }); + + await expect(connector.startAuthorization({ + subject, + companyId, + profile: "github.code", + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", + returnState: "state-malformed-direct", + })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); + }); + it("binds active GitHub installations to proof from the current user token", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index cac5782a08..d03f7011bc 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -93,6 +93,7 @@ type SealedEnvelope = { type ConnectorResponse = { confirmationUrl?: unknown; + authorizationUrl?: unknown; handoff?: unknown; expiresAt?: unknown; scopes?: unknown; @@ -348,14 +349,45 @@ export function createPaperclipCloudConnector(input: { if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") { throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE"); } - const confirmationUrl = new URL(response.confirmationUrl); - const expectedBroker = new URL(config.baseUrl); - if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") { + let confirmationUrl: URL; + try { + confirmationUrl = new URL(response.confirmationUrl); + } catch { throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE"); } + const expectedBroker = new URL(config.baseUrl); + if ( + confirmationUrl.origin !== expectedBroker.origin + || confirmationUrl.pathname !== "/connections/confirm" + || confirmationUrl.username + || confirmationUrl.password + || confirmationUrl.hash + ) { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE"); + } + let authorizationUrl: URL | undefined; + if (response.authorizationUrl !== undefined) { + if (typeof response.authorizationUrl !== "string") { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE"); + } + try { + authorizationUrl = new URL(response.authorizationUrl); + } catch { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE"); + } + if ( + authorizationUrl.protocol !== "https:" + || authorizationUrl.username + || authorizationUrl.password + || authorizationUrl.hash + || !isExpectedProviderAuthorizationUrl(profile, authorizationUrl) + ) { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE"); + } + } const handoff = parseCloudHandoff(response.handoff); return { - authorizationUrl: confirmationUrl.toString(), + authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(), expiresAt: response.expiresAt, ...(handoff ? { handoff } : {}), }; @@ -647,6 +679,16 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): return { provider: "google", scopes: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes }; } +function isExpectedProviderAuthorizationUrl( + profile: PaperclipCloudConnectorProfileId, + url: URL, +): boolean { + if (isGitHubConnectorProfileId(profile)) { + return url.origin === "https://github.com" && url.pathname === "/login/oauth/authorize"; + } + return url.origin === "https://accounts.google.com" && url.pathname === "/o/oauth2/v2/auth"; +} + function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value); } diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index b769600b66..dab0571889 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -96,6 +96,62 @@ import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayloa type Step = "gallery" | "access" | "key" | "success"; export type OAuthConnectPhase = "entry" | "starting" | "redirecting" | "error"; +type EnrollmentAccessState = { + companyId: string; + grantKind: ConnectionGrantKind; + installChoice: "specific" | "all"; + agentIds: string[]; +}; + +function enrollmentAccessStorageKey(appKey: string): string { + return `paperclip.connector-enrollment-access:${appKey}`; +} + +function validEnrollmentAccessState(value: unknown): value is EnrollmentAccessState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + if (typeof candidate.companyId !== "string" || !candidate.companyId.trim()) return false; + if (!(candidate.grantKind === "user" || candidate.grantKind === "agent" || candidate.grantKind === "organization")) { + return false; + } + if (candidate.installChoice !== "specific" && candidate.installChoice !== "all") return false; + if (!Array.isArray(candidate.agentIds) || candidate.agentIds.some((id) => typeof id !== "string" || !id.trim())) { + return false; + } + const agentIds = new Set(candidate.agentIds); + if (agentIds.size !== candidate.agentIds.length) return false; + if (candidate.grantKind === "agent") { + return candidate.installChoice === "specific" && agentIds.size === 1; + } + return candidate.installChoice === "all" ? agentIds.size === 0 : agentIds.size > 0; +} + +function saveEnrollmentAccessState( + companyId: string, + appKey: string, + state: Omit, +): void { + try { + window.sessionStorage.setItem(enrollmentAccessStorageKey(appKey), JSON.stringify({ ...state, companyId })); + } catch { + // Browser storage can be unavailable under restrictive privacy settings. + // The callback will safely use the provider's defaults in that case. + } +} + +function consumeEnrollmentAccessState(appKey: string): EnrollmentAccessState | null { + const key = enrollmentAccessStorageKey(appKey); + try { + const raw = window.sessionStorage.getItem(key); + window.sessionStorage.removeItem(key); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + return validEnrollmentAccessState(parsed) ? parsed : null; + } catch { + return null; + } +} + function githubRecoveryUrl(value: string | null): string | null { if (!value) return null; try { @@ -124,6 +180,19 @@ const ROUTE_STAGE_BY_STEP: Partial> = { success: "complete", }; +export function requestedConnectionInitialStep(input: { + requestedAppKey: string | undefined; + routeStage: string | null; + resumeConnectionId: string | null; + hasPrefilledLink: boolean; + zapierSource: boolean; +}): Step { + if (input.requestedAppKey) { + return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access"; + } + return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery"; +} + export function requestedConnectionEntry(input: { requestedAppKey: string; galleryApps: readonly AppDefinition[]; @@ -302,6 +371,17 @@ function recommendedSetupConnectionMethod( : null; } +function recommendedManagedConnectorMethod( + entry: AppDefinition | null | undefined, +): ConnectionMethodDef | null { + return recommendedSetupConnectionMethod( + (entry?.methods ?? []).filter((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector", + ), + ); +} + function canUseAutomaticOAuthFastPath(entry: AppDefinition | null | undefined): boolean { if (!entry) return false; const methods = getAvailableConnectionMethods(entry); @@ -440,6 +520,7 @@ export function ConnectionSetupFlow({ const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined; const sourceSlug = searchParams.get("source")?.trim() || null; const createNewConnection = searchParams.get("new") === "1"; + const routeStage = searchParams.get("stage")?.trim() || null; const resumeConnectionId = searchParams.get("resume")?.trim() || null; const oauthCallbackOutcome = searchParams.get("oauth"); const oauthCallbackCode = searchParams.get("code"); @@ -464,6 +545,13 @@ export function ConnectionSetupFlow({ const zapierSource = (serviceSlug ?? sourceSlug ?? appKey) === "zapier"; const requestedAppKey = zapierSource ? undefined : routeAppKey; const byo = host === "page" && (byoOnly || searchParams.get("byo") === "1"); + const [restoredEnrollmentAccess] = useState(() => + host === "page" + && searchParams.get("cloud_connector") === "enrolled" + && requestedAppKey + ? consumeEnrollmentAccessState(requestedAppKey) + : null, + ); // Prefill arrives from the app page for reconnects; read once so later // wizard navigation doesn't fight the URL. @@ -476,11 +564,13 @@ export function ConnectionSetupFlow({ }; }); - const [step, setStep] = useState( - requestedAppKey - ? resumeConnectionId ? "key" : "access" - : prefill.link || zapierSource ? "access" : "gallery", - ); + const [step, setStep] = useState(() => requestedConnectionInitialStep({ + requestedAppKey, + routeStage, + resumeConnectionId, + hasPrefilledLink: Boolean(prefill.link), + zapierSource, + })); const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); @@ -509,7 +599,7 @@ export function ConnectionSetupFlow({ const [access, setAccess] = useState<"all" | "specific">("all"); const [agentIds, setAgentIds] = useState>(new Set()); const [installAgentIds, setInstallAgentIds] = useState>( - () => new Set(requestedAgentId ? [requestedAgentId] : []), + () => new Set(restoredEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : [])), ); /** * Access-step selections (PAP-17835). These are chosen before the credential @@ -517,10 +607,10 @@ export function ConnectionSetupFlow({ * backwards through the wizard. */ const [grantKind, setGrantKind] = useState( - reconnectGrantKindHint ?? "organization", + restoredEnrollmentAccess?.grantKind ?? reconnectGrantKindHint ?? "organization", ); const [installChoice, setInstallChoice] = useState<"specific" | "all">( - requestedAgentId ? "specific" : "all", + restoredEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all"), ); const resumingAfterOAuthFailure = Boolean( resumeConnectionId @@ -729,6 +819,16 @@ export function ConnectionSetupFlow({ || candidate.oauthStrategy === "paperclip_id_connector" ), ); + // Before a self-hosted instance enrolls, the server intentionally withholds + // platform-managed methods from the advertised gallery. The setup route still + // needs the managed method's identity model, labels, and defaults because the + // next step is enrollment for that exact method—not the visible PAT/BYO + // compatibility fallback. + const preEnrollmentManagedMethod = entry + && requestedDefinitionUsesManagedConnector + && !entryAdvertisesManagedConnector + ? recommendedManagedConnectorMethod(fullRequestedDefinition) + : null; const connectorEnrollmentQuery = useQuery({ queryKey: ["cloud-connector", "enrollment"], queryFn: () => toolsApi.getCloudConnectorEnrollment(), @@ -739,6 +839,14 @@ export function ConnectionSetupFlow({ ), }); const [connectorEnrollmentError, setConnectorEnrollmentError] = useState(null); + const preserveEnrollmentAccess = useCallback(() => { + if (!selectedCompanyId || !requestedAppKey) return; + saveEnrollmentAccessState(selectedCompanyId, requestedAppKey, { + grantKind, + installChoice, + agentIds: installChoice === "specific" ? [...installAgentIds] : [], + }); + }, [grantKind, installAgentIds, installChoice, requestedAppKey, selectedCompanyId]); const openConnectorEnrollment = useCallback((verificationUrl: string) => { const target = resolveAuthorizationTarget(verificationUrl); if (!target.ok) { @@ -1158,19 +1266,38 @@ export function ConnectionSetupFlow({ setCuratedOAuthClientId(""); setCuratedOAuthClientSecret(""); setVercelConnector(""); - const initialMethod = recommendedSetupConnectionMethod(methods); + const requestedEntryAdvertisesManagedConnector = requestedEntry.methods.some((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector" + ); + const initialMethod = ( + requestedDefinitionUsesManagedConnector && !requestedEntryAdvertisesManagedConnector + ? recommendedManagedConnectorMethod(fullRequestedDefinition) + : null + ) ?? recommendedSetupConnectionMethod(methods); setConnectionMethodKey(initialMethod?.key ?? ""); setConfigValues(defaultMethodConfig(initialMethod)); setGoogleSheetsLinks(""); setGoogleSheetsError(null); setConnectResult(null); - setGrantKind(reconnectGrantKind ?? defaultGrantKindFor(initialMethod)); - setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : [])); - setInstallChoice(requestedAgentId ? "specific" : "all"); + const matchingEnrollmentAccess = restoredEnrollmentAccess?.companyId === selectedCompanyId + ? restoredEnrollmentAccess + : null; + setGrantKind(reconnectGrantKind ?? matchingEnrollmentAccess?.grantKind ?? defaultGrantKindFor(initialMethod)); + setInstallAgentIds(new Set( + matchingEnrollmentAccess?.agentIds ?? (requestedAgentId ? [requestedAgentId] : []), + )); + setInstallChoice(matchingEnrollmentAccess?.installChoice ?? (requestedAgentId ? "specific" : "all")); // Route/service selection initializes the wizard once. Later renders must // preserve the user's current step in both hosts instead of snapping back // to Access after they continue. - setStep(resumeConnectionId ? "key" : "access"); + setStep(requestedConnectionInitialStep({ + requestedAppKey, + routeStage, + resumeConnectionId, + hasPrefilledLink: Boolean(prefill.link), + zapierSource, + })); } if (automaticOAuth && ( @@ -1200,8 +1327,12 @@ export function ConnectionSetupFlow({ reconnectConnectionId, reconnectSourceMatches, resumeConnectionId, + fullRequestedDefinition, requestedAppKey, requestedAgentId, + restoredEnrollmentAccess, + routeStage, + zapierSource, ]); // Resume the exact method and non-secret provider configuration that the @@ -1674,6 +1805,9 @@ export function ConnectionSetupFlow({ entry?.name ?? (linkName.trim() || defaultGenericMcpName(linkUrl) || "this app"); const credentialSourceMethods = connectionMethodsForCredentialSource(entry, credentialSource); + const setupCredentialSourceMethods = preEnrollmentManagedMethod + ? [preEnrollmentManagedMethod] + : credentialSourceMethods; const credentialSourceApps = vercelConnectMode ? (galleryQuery.data?.apps ?? []).filter( (app) => connectionMethodsForCredentialSource(app, credentialSource).length > 0, @@ -1684,9 +1818,9 @@ export function ConnectionSetupFlow({ : null; const stepLabels = zapierSource ? ZAPIER_STEP_LABELS - : entry && credentialSourceMethods.length > 1 + : entry && setupCredentialSourceMethods.length > 1 ? ["Access", "Choose connection"] - : entry && credentialSourceMethods[0]?.auth === "oauth" + : entry && setupCredentialSourceMethods[0]?.auth === "oauth" ? ["Access", "Sign in"] : isGoogleSheetsRobotMethod(entry, connectionMethodKey) ? ["Access", "Share sheet"] @@ -1697,8 +1831,8 @@ export function ConnectionSetupFlow({ // credential, so it reads the selected method's auth kind. const accessStepMethod = entry ? (connectionMethodKey - ? credentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null - : credentialSourceMethods[0] ?? null) + ? setupCredentialSourceMethods.find((m) => m.key === connectionMethodKey) ?? null + : setupCredentialSourceMethods[0] ?? null) : null; const accessStepAuthKind: ToolConnectionAuthKind = entry ? accessStepMethod?.auth ?? "none" @@ -1781,43 +1915,49 @@ export function ConnectionSetupFlow({ {step === "key" && entry && showConnectorEnrollmentStep ? (
-
-
- +
+
+
+ +
+
+

+ Connect with Paperclip +

+

+ You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once). +

+
-
-

- Connect with Paperclip -

+ + {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( + + {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} + + ) : null} + +
+ +
- - {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( - - {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} - - ) : null} - -
- - -
) : step === "key" && entry ? (
@@ -3449,7 +3591,7 @@ export function AccessStep({ queryFn: () => agentsApi.list(companyId), }); const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated"); - // "Just agents I pick" means agents this person may actually edit. When the server + // "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server // has not told us, fall back to every live agent rather than an empty list — // an empty picker would read as "you have no agents". const editableAgentIds = capabilities?.editableAgentIds; @@ -3479,13 +3621,22 @@ export function AccessStep({ const lockedAgentName = lockedAgentId ? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent" : null; + const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?"; + const agentAccessHeading = grantKind === "agent" + ? "Which agent owns this GitHub account?" + : githubIdentity && grantKind === "user" + ? "Which agents may use your GitHub when you’re responsible?" + : githubIdentity + ? "Which agents may use the shared GitHub account?" + : "Which agents can use this connection?"; + const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?"; return (
-

{githubIdentity ? "Which GitHub identity should this use?" : "Which humans can use this credential?"}

+

{identityHeading}

{identityLoading ? (
@@ -3500,17 +3651,28 @@ export function AccessStep({ ) : (
-

{grantKind === "agent" ? "Which agent owns this GitHub account?" : "Which agents can use this connection?"}

+

{agentAccessHeading}

{preserveAgentAccess ? (