From 5f870908947d9a51f7eea841c8957cdc290d5ff9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:33:13 -0500 Subject: [PATCH 1/2] Make managed Cloud OAuth handoffs invisible (#12790) 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 > - Apps let people give agents governed access to external providers > - Paperclip Cloud brokers shared provider authorization for managed stacks > - The managed flow sent the browser through a confirmation page after the tenant had already prepared sign-in > - A lost confirmation response could also show an expired-session error before the provider page opened > - This pull request adds an opaque handoff contract and one shared tenant coordinator > - The benefit is a direct and recoverable transition from Paperclip to every Cloud-brokered provider ## Linked Issues or Issue Description **What happened?** A managed Paperclip Cloud connection opened the Cloud confirmation route. A response-loss race could show an expired-session error while the authorization still continued. **Expected behavior** The current Paperclip loading state must stay visible while the tenant exchanges an opaque session. The browser must then open the provider directly. Self-hosted and direct OAuth must keep their existing behavior. **Steps to reproduce** 1. Open Apps on a Paperclip Cloud stack. 2. Start a managed provider connection. 3. Select Continue to sign in. 4. Observe that the browser visits the Cloud confirmation route before it reaches the provider. **Paperclip version or commit** `b872cd3d1b404bdaff70af493a2973ceb7e5d6ec` **Deployment mode** Paperclip Cloud hosted stack. No related open issue or pull request was found in the repository search. ## What Changed - Add a backward-compatible opaque Cloud handoff to the shared OAuth start contract. - Validate the Cloud descriptor on the server and expose no browser-selected endpoint. - Exchange managed handoffs through one fixed same-origin route in every Apps OAuth launcher. - Keep dialog popups reserved before asynchronous work and retain the tenant loading state. - Add recent-login resume storage, bounded retry behavior, terminal tenant errors, tests, and Storybook states. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - Focused connector and UI suites: 184 passed and 202 skipped. - `pnpm build` - `pnpm build-storybook` - The full local suite reached one unrelated macOS path-alias failure. The untouched test expected `/var/...` and received the equivalent `/private/var/...`. The same test reproduces in isolation. ## Risks - A malformed managed descriptor now fails closed in Paperclip instead of opening a URL. - A legacy Cloud deployment can omit the descriptor. Paperclip then uses the existing validated confirmation URL. - Direct provider OAuth and self-hosted flows do not receive a handoff and remain unchanged. - Rollback is a normal revert of this commit because the contract is optional and backward compatible. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5.6, reasoning mode, tool use, code execution, and browser verification. ## 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 - [ ] 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 --- packages/shared/src/types/tool-access.ts | 12 ++ .../src/__tests__/tool-access-service.test.ts | 12 ++ server/src/routes/tool-access.ts | 5 +- .../paperclip-cloud-connector.test.ts | 44 ++++- .../src/services/paperclip-cloud-connector.ts | 27 ++- server/src/services/tool-access.ts | 1 + ui/src/App.tsx | 2 + ui/src/api/tools.ts | 2 +- .../connections/ConnectionSetupFlow.tsx | 77 +++++--- ui/src/lib/oauthHandoff.test.ts | 95 +++++++++ ui/src/lib/oauthHandoff.ts | 180 ++++++++++++++++++ ui/src/pages/apps/AppDetail.test.tsx | 31 +++ ui/src/pages/apps/AppDetail.tsx | 42 ++-- ui/src/pages/apps/AppsConnect.test.tsx | 42 ++++ .../apps/PaperclipCloudOAuthHandoff.test.tsx | 101 ++++++++++ .../pages/apps/PaperclipCloudOAuthHandoff.tsx | 102 ++++++++++ .../pages/apps/app-detail/AdvancedPanel.tsx | 23 ++- ui/src/pages/tools/PasteConfigTab.test.tsx | 33 ++++ ui/src/pages/tools/PasteConfigTab.tsx | 29 +-- .../managed-cloud-oauth-handoff.stories.tsx | 37 ++++ 20 files changed, 830 insertions(+), 67 deletions(-) create mode 100644 ui/src/lib/oauthHandoff.test.ts create mode 100644 ui/src/lib/oauthHandoff.ts create mode 100644 ui/src/pages/apps/PaperclipCloudOAuthHandoff.test.tsx create mode 100644 ui/src/pages/apps/PaperclipCloudOAuthHandoff.tsx create mode 100644 ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index f5a2a32f53..10c1abe009 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -1103,6 +1103,12 @@ export interface ToolAppConnectionActionSummary { */ export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr" | "manual"; +/** Opaque managed-Cloud exchange; clients never treat the session as a URL. */ +export interface ToolOAuthHandoff { + kind: "paperclip_cloud"; + session: string; +} + /** * What an unknown remote MCP endpoint told Paperclip it needs, so the wizard can * branch without re-probing. `manualClientRequired` means discovery succeeded but @@ -1112,6 +1118,7 @@ export type ToolOAuthClientRegistrationSource = "preconfigured" | "cimd" | "dcr" export interface ConnectToolAppAuthChallenge { kind: "oauth"; startUrl: string | null; + handoff?: ToolOAuthHandoff; issuer?: string | null; resource?: string | null; registrationSource?: ToolOAuthClientRegistrationSource | null; @@ -1159,6 +1166,11 @@ export interface ToolOAuthStartResult { provider: string; authorizationUrl: string; expiresAt: string; + /** + * Opaque Paperclip Cloud authorization handoff. The board submits this only + * to its fixed same-origin Cloud endpoint; it is never treated as a URL. + */ + handoff?: ToolOAuthHandoff; /** Canonical authorization-server issuer this run is bound to, when discovered. */ issuer?: string | null; /** RFC 8707 resource indicator sent with the request. */ diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index a329f14bf1..3eda01db8d 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5040,6 +5040,14 @@ describeEmbeddedPostgres("tool access service", () => { await grantBoardUser(db, company.id, userId, [], "owner"); const profile = "drive.read" as const; const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); + connector.startAuthorization = vi.fn(async ({ returnState }) => ({ + authorizationUrl: `https://my.example.test/connections/confirm?session=legacy&state=${encodeURIComponent(returnState)}`, + expiresAt: new Date(Date.now() + 600_000).toISOString(), + handoff: { + kind: "paperclip_cloud" as const, + session: "cloud_session_abcdefghijklmnop", + }, + })); const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); const actor = { actorType: "user" as const, actorId: userId }; const driveDefinition = getConnectableAppDefinition("google-drive")!; @@ -5062,6 +5070,10 @@ describeEmbeddedPostgres("tool access service", () => { actor, }); const state = new URL(started.authorizationUrl).searchParams.get("state")!; + expect(started.handoff).toEqual({ + kind: "paperclip_cloud", + session: "cloud_session_abcdefghijklmnop", + }); const app = createRouteApp( db, boardSessionActor(company.id, "owner", userId), diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 2eb41454a1..ea16fe6d3e 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -735,7 +735,7 @@ function connectorEnrollmentPrincipal(req: Request): string { returnTo: req.body.returnTo, redirectUri: oauthRedirectUri(req), }); - res.json({ url: result.authorizationUrl }); + res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); }); router.post("/agents/me/connections/:connectionId/token", validate(connectionTokenRequestSchema), async (req, res) => { @@ -870,6 +870,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ...(req.body.interactionId ? { interactionId: req.body.interactionId } : {}), }); result.auth.startUrl = start.authorizationUrl; + result.auth.handoff = start.handoff; result.auth.issuer = start.issuer ?? result.auth.issuer ?? null; result.auth.resource = start.resource ?? result.auth.resource ?? null; result.auth.registrationSource = start.registrationSource ?? null; @@ -926,7 +927,7 @@ function connectorEnrollmentPrincipal(req: Request): string { scopes: req.body.scopes, returnTo: req.body.returnTo, }); - res.json({ url: result.authorizationUrl }); + res.json({ url: result.authorizationUrl, ...(result.handoff ? { handoff: result.handoff } : {}) }); }, ); diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 6fc923e5f0..9c1431d8bb 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -69,6 +69,10 @@ describe("Paperclip Cloud connector", () => { }); return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + handoff: { + kind: "tenant_background", + session: "broker_state_abcdefghijklmnop", + }, expiresAt: "2026-08-21T20:00:00.000Z", }, { status: 201 }); }); @@ -79,7 +83,45 @@ describe("Paperclip Cloud connector", () => { companyId, returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "state-1", - })).resolves.toMatchObject({ authorizationUrl: expect.stringContaining("/connections/confirm") }); + })).resolves.toMatchObject({ + authorizationUrl: expect.stringContaining("/connections/confirm"), + handoff: { + kind: "paperclip_cloud", + session: "broker_state_abcdefghijklmnop", + }, + }); + }); + + it("keeps legacy session responses compatible and rejects malformed handoff descriptors", async () => { + const keys = config(); + const legacy = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => Response.json({ + confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + expiresAt: "2099-08-21T20:00:00.000Z", + })) as typeof fetch, + }); + await expect(legacy.startAuthorization({ + subject, + companyId, + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", + returnState: "state-legacy", + })).resolves.not.toHaveProperty("handoff"); + + const malformed = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => Response.json({ + confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state", + handoff: { kind: "tenant_background", session: "not valid" }, + expiresAt: "2099-08-21T20:00:00.000Z", + })) as typeof fetch, + }); + await expect(malformed.startAuthorization({ + subject, + companyId, + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", + returnState: "state-malformed", + })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); }); it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index f25f6f6751..b01b002062 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -67,6 +67,7 @@ type SealedEnvelope = { type ConnectorResponse = { confirmationUrl?: unknown; + handoff?: unknown; expiresAt?: unknown; scopes?: unknown; claimId?: unknown; @@ -318,7 +319,12 @@ export function createPaperclipCloudConnector(input: { if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") { throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE"); } - return { authorizationUrl: confirmationUrl.toString(), expiresAt: response.expiresAt }; + const handoff = parseCloudHandoff(response.handoff); + return { + authorizationUrl: confirmationUrl.toString(), + expiresAt: response.expiresAt, + ...(handoff ? { handoff } : {}), + }; }, async claim(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; claimId: string; redemptionId: string }) { const profile = values.profile ?? "gmail.draft"; @@ -340,6 +346,25 @@ export function createPaperclipCloudConnector(input: { }; } +function parseCloudHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE"); + } + const record = value as Record; + const session = record.session; + if ( + record.kind !== "tenant_background" + || typeof session !== "string" + || session.length < 16 + || session.length > 512 + || !/^[A-Za-z0-9_-]+$/.test(session) + ) { + throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid handoff", "CONNECTOR_BAD_RESPONSE"); + } + return { kind: "paperclip_cloud", session }; +} + export type PaperclipCloudConnector = ReturnType; export type PaperclipCloudGoogleWorkspaceConnector = PaperclipCloudConnector; diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 89340a5d8b..eed7097dae 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -9827,6 +9827,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} provider: "google", authorizationUrl: session.authorizationUrl, expiresAt: expiresAt.toISOString(), + ...(session.handoff ? { handoff: session.handoff } : {}), issuer: "https://accounts.google.com", resource: galleryMethod.defaults?.serverUrl ?? null, registrationSource: null, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a75caa480e..d047e0d3c4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -71,6 +71,7 @@ import { canEnterAppsConnect } from "./pages/apps/app-connect-policy"; import { AppsReview } from "./pages/apps/AppsReview"; import { AppDetail } from "./pages/apps/AppDetail"; import { AppNotConnected } from "./pages/apps/AppNotConnected"; +import { PaperclipCloudOAuthHandoffPage } from "./pages/apps/PaperclipCloudOAuthHandoff"; import { GatewaysList } from "./pages/apps/gateways/GatewaysList"; import { GatewayDetail } from "./pages/apps/gateways/GatewayDetail"; import { CompanySkills } from "./pages/CompanySkills"; @@ -729,6 +730,7 @@ export function App() { return ( <> + } /> } /> } /> } /> diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index fb66a3326f..db24ced3f3 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -396,7 +396,7 @@ export const toolsApi = { connectionId: string, input: { subjectUserId: string; scopes?: string[]; returnTo?: string }, ) => - api.post<{ url: string }>( + api.post<{ url: string; handoff?: ToolOAuthStartResult["handoff"] }>( `/companies/${companyId}/tools/connections/${connectionId}/start-authorization`, input, ), diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 1f94c056d0..98b1c12994 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -28,6 +28,7 @@ import type { ToolConnectionAuthKind, ToolConnectionCredentialSource, ToolConnectionCreateCapabilities, + ToolOAuthStartResult, } from "@paperclipai/shared"; import { connectionMethodAcceptsCustomerOAuthClient, @@ -63,6 +64,7 @@ import { cn } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/clipboard"; import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { redactUrlSecrets } from "@/lib/redact-url-secrets"; import { AppLogo } from "@/pages/apps/AppLogo"; import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display"; @@ -502,6 +504,7 @@ export function ConnectionSetupFlow({ const hydratedResumeConnectionIdRef = useRef(null); const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState(null); const oauthPopupRef = useRef(null); + const oauthHandoffAbortRef = useRef(null); const [showConnectionChoice, setShowConnectionChoice] = useState( existingConnections.length > 0 && Boolean(onUseExisting), ); @@ -534,6 +537,38 @@ export function ConnectionSetupFlow({ popup.focus(); }, [host, onPhaseChange]); + const prepareAndOpenOAuth = useCallback(async ( + start: Pick, + ) => { + oauthHandoffAbortRef.current?.abort(); + const controller = new AbortController(); + oauthHandoffAbortRef.current = controller; + try { + const target = await prepareOAuthNavigation(start, { signal: controller.signal }); + if (target.kind === "reauthentication") { + const destination = host === "dialog" ? oauthPopupRef.current : window; + if (!destination || destination.closed || !start.handoff) { + throw new Error("Paperclip couldn’t preserve this sign-in while refreshing your account."); + } + savePendingCloudHandoff(start.handoff.session, destination.sessionStorage); + setOAuthPhase("starting"); + } else { + setAuthorizationHost(target.host); + setOAuthPhase("redirecting"); + } + openAuthorization(target.url); + } catch (error) { + if (controller.signal.aborted) return; + setOAuthPhase("error"); + setOAuthError(error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again."); + onPhaseChange?.("needs_retry"); + } finally { + if (oauthHandoffAbortRef.current === controller) oauthHandoffAbortRef.current = null; + } + }, [host, onPhaseChange, openAuthorization]); + + useEffect(() => () => oauthHandoffAbortRef.current?.abort(), []); + useEffect(() => { if (host !== "dialog" || !connectionIntentId) return; const receiveOAuthOutcome = (event: MessageEvent) => { @@ -804,20 +839,7 @@ export function ConnectionSetupFlow({ asCurrentUser: connection.credentialPolicy === "per_user", ...(connectionIntentId ? { interactionId: connectionIntentId } : {}), }), - onSuccess: ({ authorizationUrl }) => { - // The endpoint chose this address, so it is checked here too — this is the - // line where an unsafe scheme would actually run (PAP-17099). - const target = resolveAuthorizationTarget(authorizationUrl); - if (!target.ok) { - setOAuthPhase("error"); - setOAuthError(target.message); - onPhaseChange?.("needs_retry"); - return; - } - setAuthorizationHost(target.host); - setOAuthPhase("redirecting"); - openAuthorization(target.url); - }, + onSuccess: (start) => void prepareAndOpenOAuth(start), onError: (error) => { const details = error instanceof ApiError && error.body && typeof error.body === "object" ? (error.body as { details?: { code?: unknown } }).details @@ -963,18 +985,11 @@ export function ConnectionSetupFlow({ startOAuth(result.connection); return; } - const target = resolveAuthorizationTarget(startUrl); - if (!target.ok) { - setGenericOAuthPending(true); - setOAuthPhase("error"); - setOAuthError(target.message); - onPhaseChange?.("needs_retry"); - return; - } - setAuthorizationHost(target.host); - setOAuthPhase("redirecting"); setGenericOAuthPending(true); - openAuthorization(target.url); + void prepareAndOpenOAuth({ + authorizationUrl: startUrl, + handoff: result.auth.handoff, + }); return; } setLinkGuidance(null); @@ -1550,11 +1565,15 @@ export function ConnectionSetupFlow({ } }} onBack={() => { + oauthHandoffAbortRef.current?.abort(); setOAuthPhase("entry"); setOAuthError(null); setAppStep("access"); }} - onCancel={onCancel ?? (() => navigate("/apps"))} + onCancel={() => { + oauthHandoffAbortRef.current?.abort(); + (onCancel ?? (() => navigate("/apps")))(); + }} /> ); } @@ -1585,11 +1604,15 @@ export function ConnectionSetupFlow({ setOAuthPhase("entry"); }} onBack={() => { + oauthHandoffAbortRef.current?.abort(); setGenericOAuthPending(false); setOAuthPhase("entry"); setOAuthError(null); }} - onCancel={onCancel ?? (() => navigate("/apps"))} + onCancel={() => { + oauthHandoffAbortRef.current?.abort(); + (onCancel ?? (() => navigate("/apps")))(); + }} /> ); } diff --git a/ui/src/lib/oauthHandoff.test.ts b/ui/src/lib/oauthHandoff.test.ts new file mode 100644 index 0000000000..7c7a5b14db --- /dev/null +++ b/ui/src/lib/oauthHandoff.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearPendingCloudHandoff, + OAuthHandoffError, + prepareOAuthNavigation, + readPendingCloudHandoff, + savePendingCloudHandoff, +} from "./oauthHandoff"; + +const SESSION = "cloud_session_abcdefghijklmnop"; + +afterEach(() => { + vi.restoreAllMocks(); + window.sessionStorage.clear(); +}); + +describe("Paperclip Cloud OAuth handoff", () => { + it("uses the fixed same-origin endpoint and never navigates to the legacy confirmation URL", async () => { + const request = vi.fn(async () => Response.json({ + authorizationUrl: "https://provider.example.test/authorize?state=one", + })); + + const target = await prepareOAuthNavigation({ + authorizationUrl: "https://my.example.test/connections/confirm?session=legacy", + handoff: { kind: "paperclip_cloud", session: SESSION }, + }, { request: request as typeof fetch }); + + expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({ + method: "POST", + credentials: "include", + body: JSON.stringify({ session: SESSION }), + })); + expect(target).toMatchObject({ kind: "authorization", host: "provider.example.test" }); + expect(target.url).not.toContain("/connections/confirm"); + }); + + it("retries one transient failure with the identical opaque session", async () => { + const request = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(Response.json({ authorizationUrl: "https://provider.example.test/authorize" })); + + await prepareOAuthNavigation({ + authorizationUrl: "https://my.example.test/connections/confirm", + handoff: { kind: "paperclip_cloud", session: SESSION }, + }, { request }); + + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[0]?.[1]?.body).toBe(request.mock.calls[1]?.[1]?.body); + }); + + it("accepts only the fixed same-origin reauthentication route", async () => { + const request = vi.fn(async () => Response.json({ + error: "RECENT_LOGIN_REQUIRED", + reauthenticationUrl: `${window.location.origin}/cloud/connections/reauth?session=${SESSION}`, + }, { status: 401 })); + + await expect(prepareOAuthNavigation({ + authorizationUrl: "https://my.example.test/connections/confirm", + handoff: { kind: "paperclip_cloud", session: SESSION }, + }, { request: request as typeof fetch })).resolves.toMatchObject({ kind: "reauthentication" }); + + request.mockResolvedValueOnce(Response.json({ + error: "RECENT_LOGIN_REQUIRED", + reauthenticationUrl: `https://evil.example/cloud/connections/reauth?session=${SESSION}`, + }, { status: 401 })); + await expect(prepareOAuthNavigation({ + authorizationUrl: "https://my.example.test/connections/confirm", + handoff: { kind: "paperclip_cloud", session: SESSION }, + }, { request: request as typeof fetch })).rejects.toMatchObject({ code: "forbidden" }); + }); + + it("rejects malformed descriptors without making a request", async () => { + const request = vi.fn(); + await expect(prepareOAuthNavigation({ + authorizationUrl: "https://provider.example.test/authorize", + handoff: { kind: "paperclip_cloud", session: "bad session" }, + }, { request: request as typeof fetch })).rejects.toBeInstanceOf(OAuthHandoffError); + expect(request).not.toHaveBeenCalled(); + }); + + it("keeps the opaque handoff in per-tab storage for reauthentication resume", () => { + savePendingCloudHandoff(SESSION); + expect(readPendingCloudHandoff()).toEqual({ kind: "paperclip_cloud", session: SESSION }); + clearPendingCloudHandoff(); + expect(readPendingCloudHandoff()).toBeNull(); + }); + + it("keeps direct and legacy OAuth on the validated authorization URL", async () => { + await expect(prepareOAuthNavigation({ + authorizationUrl: "https://provider.example.test/authorize", + })).resolves.toMatchObject({ kind: "authorization", host: "provider.example.test" }); + }); +}); diff --git a/ui/src/lib/oauthHandoff.ts b/ui/src/lib/oauthHandoff.ts new file mode 100644 index 0000000000..5de02dd0da --- /dev/null +++ b/ui/src/lib/oauthHandoff.ts @@ -0,0 +1,180 @@ +import type { ToolOAuthStartResult } from "@paperclipai/shared"; +import { resolveAuthorizationTarget } from "./authorizationUrl"; + +const CLOUD_HANDOFF_PATH = "/cloud/connections/handoff"; +const CLOUD_REAUTH_PATH = "/cloud/connections/reauth"; +const PENDING_HANDOFF_KEY = "paperclip.cloud-oauth-handoff.v1"; + +export type PreparedOAuthNavigation = { + kind: "authorization" | "reauthentication"; + url: string; + host: string; +}; + +type PendingCloudHandoff = { + version: 1; + session: string; + savedAt: number; +}; + +export class OAuthHandoffError extends Error { + constructor( + message: string, + readonly code: + | "invalid_handoff" + | "expired" + | "forbidden" + | "unavailable", + ) { + super(message); + this.name = "OAuthHandoffError"; + } +} + +function parseHandoff(value: unknown): { kind: "paperclip_cloud"; session: string } | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff"); + } + const handoff = value as Record; + if ( + handoff.kind !== "paperclip_cloud" + || typeof handoff.session !== "string" + || handoff.session.length < 16 + || handoff.session.length > 512 + || !/^[A-Za-z0-9_-]+$/.test(handoff.session) + ) { + throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff"); + } + return { kind: "paperclip_cloud", session: handoff.session }; +} + +function handoffFailure(status: number, code: unknown): OAuthHandoffError { + if (status === 404 || code === "SESSION_NOT_AVAILABLE") { + return new OAuthHandoffError("This sign-in expired. Start the connection again.", "expired"); + } + if (status === 401 || status === 403) { + return new OAuthHandoffError("Paperclip Cloud could not authorize this connection for your account.", "forbidden"); + } + return new OAuthHandoffError("Paperclip Cloud couldn’t prepare secure sign-in. Try again.", "unavailable"); +} + +async function postCloudHandoff( + session: string, + options: { signal?: AbortSignal; request?: typeof fetch }, +): Promise { + const request = options.request ?? fetch; + let response: Response | undefined; + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + response = await request(CLOUD_HANDOFF_PATH, { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + credentials: "include", + body: JSON.stringify({ session }), + signal: options.signal, + }); + if (response.status < 500 || attempt === 1) return response; + } catch (error) { + if (options.signal?.aborted || (error instanceof DOMException && error.name === "AbortError")) throw error; + lastError = error; + if (attempt === 1) break; + } + } + if (response) return response; + throw new OAuthHandoffError( + lastError instanceof Error ? lastError.message : "Paperclip Cloud couldn’t prepare secure sign-in. Try again.", + "unavailable", + ); +} + +function exactReauthenticationTarget(value: unknown, session: string): PreparedOAuthNavigation | null { + if (typeof value !== "string" || typeof window === "undefined") return null; + try { + const url = new URL(value, window.location.origin); + if ( + url.origin !== window.location.origin + || url.pathname !== CLOUD_REAUTH_PATH + || url.username + || url.password + || url.hash + || url.searchParams.size !== 1 + || url.searchParams.get("session") !== session + ) return null; + return { kind: "reauthentication", url: url.toString(), host: url.host }; + } catch { + return null; + } +} + +/** + * Resolve a start response into the next browser navigation. + * + * Managed Cloud sessions are exchanged only through the fixed same-origin + * endpoint. Legacy, self-hosted, and direct provider OAuth keep using the + * server-supplied authorization URL after the existing URL safety gate. + */ +export async function prepareOAuthNavigation( + start: Pick, + options: { signal?: AbortSignal; request?: typeof fetch } = {}, +): Promise { + const handoff = parseHandoff(start.handoff); + if (!handoff) { + const target = resolveAuthorizationTarget(start.authorizationUrl); + if (!target.ok) throw new OAuthHandoffError(target.message, "invalid_handoff"); + return { kind: "authorization", url: target.url, host: target.host }; + } + + const response = await postCloudHandoff(handoff.session, options); + const body = await response.json().catch(() => null) as Record | null; + if (!response.ok) { + if (body?.error === "RECENT_LOGIN_REQUIRED") { + const reauthentication = exactReauthenticationTarget(body.reauthenticationUrl, handoff.session); + if (reauthentication) return reauthentication; + } + throw handoffFailure(response.status, body?.error); + } + const authorization = resolveAuthorizationTarget( + typeof body?.authorizationUrl === "string" ? body.authorizationUrl : undefined, + ); + if (!authorization.ok) { + throw new OAuthHandoffError("Paperclip Cloud returned an invalid provider sign-in address.", "invalid_handoff"); + } + return { kind: "authorization", url: authorization.url, host: authorization.host }; +} + +export function savePendingCloudHandoff( + session: string, + storage: Pick = window.sessionStorage, +): void { + const handoff = parseHandoff({ kind: "paperclip_cloud", session }); + if (!handoff) throw new OAuthHandoffError("Paperclip Cloud returned an invalid sign-in handoff.", "invalid_handoff"); + const pending: PendingCloudHandoff = { version: 1, session: handoff.session, savedAt: Date.now() }; + storage.setItem(PENDING_HANDOFF_KEY, JSON.stringify(pending)); +} + +export function readPendingCloudHandoff( + storage: Pick = window.sessionStorage, +): { kind: "paperclip_cloud"; session: string } | null { + const raw = storage.getItem(PENDING_HANDOFF_KEY); + if (!raw) return null; + try { + const pending = JSON.parse(raw) as Partial; + if ( + pending.version !== 1 + || typeof pending.savedAt !== "number" + || Date.now() - pending.savedAt > 15 * 60_000 + ) throw new Error("expired"); + return parseHandoff({ kind: "paperclip_cloud", session: pending.session }); + } catch { + storage.removeItem(PENDING_HANDOFF_KEY); + return null; + } +} + +export function clearPendingCloudHandoff( + storage: Pick = window.sessionStorage, +): void { + storage.removeItem(PENDING_HANDOFF_KEY); +} diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 1a2173c578..7d73333df5 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -401,6 +401,7 @@ describe("AppDetail", () => { afterEach(() => { flushSync(() => root?.unmount()); container.remove(); + vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -1504,6 +1505,36 @@ describe("AppDetail", () => { expect(navigateTopLevelMock).toHaveBeenCalledWith("https://accounts.example.test/authorize"); }); + it("keeps personal managed authorization in the tenant until the provider is ready", async () => { + const session = "personal_background_session_1234"; + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ + authorizationUrl: "https://provider.example.test/authorize?state=personal", + })); + mockParams.tab = "setup"; + getConnectionMock.mockResolvedValue(perUserConnection()); + startPersonalAuthorizationMock.mockResolvedValue({ + url: "https://my.paperclip.app/connections/confirm?session=legacy", + handoff: { kind: "paperclip_cloud", session }, + }); + + await renderAppDetail(); + await act(async () => { + findButton("Connect as me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ session }), + })); + await vi.waitFor(() => { + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://provider.example.test/authorize?state=personal", + ); + }); + expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); + }); + it("opens Permissions from app access instead of personal identity delegations", async () => { mockParams.tab = "setup"; getConnectionMock.mockResolvedValue(perUserConnection()); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 45a730dda4..5408a01d8e 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -23,8 +23,8 @@ import { accessApi } from "@/api/access"; import { authApi } from "@/api/auth"; import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "@/lib/company-members"; import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs"; -import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; @@ -372,15 +372,20 @@ export function AppDetail() { const startOAuth = useMutation({ mutationFn: () => toolsApi.startOAuth(connectionId), - onSuccess: ({ authorizationUrl }) => { - // Checked again at the navigation boundary (PAP-17099): the address came - // from the remote server, and this is where an unsafe scheme would run. - const target = resolveAuthorizationTarget(authorizationUrl); - if (!target.ok) { - pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" }); - return; + onSuccess: async (start) => { + try { + const target = await prepareOAuthNavigation(start); + if (target.kind === "reauthentication" && start.handoff) { + savePendingCloudHandoff(start.handoff.session); + } + navigateTopLevel(target.url); + } catch (error) { + pushToast({ + title: "Couldn't start sign-in", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); } - navigateTopLevel(target.url); }, onError: (error) => pushToast({ @@ -409,13 +414,20 @@ export function AppDetail() { returnTo: appTabHref(connectionId, "setup"), }); }, - onSuccess: ({ url }) => { - const target = resolveAuthorizationTarget(url); - if (!target.ok) { - pushToast({ title: "Couldn't start sign-in", body: target.message, tone: "error" }); - return; + onSuccess: async ({ url, handoff }) => { + try { + const target = await prepareOAuthNavigation({ authorizationUrl: url, handoff }); + if (target.kind === "reauthentication" && handoff) { + savePendingCloudHandoff(handoff.session); + } + navigateTopLevel(target.url); + } catch (error) { + pushToast({ + title: "Couldn't start sign-in", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); } - navigateTopLevel(target.url); }, onError: (error) => pushToast({ diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index a27733c097..a219ab77df 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -254,6 +254,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { } document.body.removeChild(container); document.body.innerHTML = ""; + vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -2396,6 +2397,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { } document.body.removeChild(container); document.body.innerHTML = ""; + vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -2620,6 +2622,46 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { expect(container.textContent).toContain("auth.example.test"); }); + it("exchanges a managed connect response in the tenant without opening Cloud confirmation", async () => { + const session = "managed_background_session_1234"; + const request = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => + input === "/cloud/connections/handoff" + ? Response.json({ authorizationUrl: "https://provider.example.test/authorize?state=managed" }) + : new Response(null, { status: 404 })); + connectAppMock.mockResolvedValue({ + connectionId: "conn-1", + application: { id: "app-1", name: "mcp.example.test" }, + connection: { id: "conn-1", credentialPolicy: "shared", status: "draft" }, + actions: { readOnly: [], canMakeChanges: [] }, + catalog: [], + suggestedDefaults: {}, + auth: { + kind: "oauth", + startUrl: "https://my.paperclip.app/connections/confirm?session=legacy", + handoff: { kind: "paperclip_cloud", session }, + }, + }); + await render(); + await gotoLinkFrame(container, "https://mcp.example.test/mcp"); + await act(async () => { + buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ session }), + })); + await vi.waitFor(() => { + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://provider.example.test/authorize?state=managed", + ); + }); + expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); + }); + /** * PAP-17099 — a generic MCP server picks its own authorization endpoint, and * `window.location.assign` is where an unsafe scheme would actually execute. diff --git a/ui/src/pages/apps/PaperclipCloudOAuthHandoff.test.tsx b/ui/src/pages/apps/PaperclipCloudOAuthHandoff.test.tsx new file mode 100644 index 0000000000..9a3aa6dbe3 --- /dev/null +++ b/ui/src/pages/apps/PaperclipCloudOAuthHandoff.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { savePendingCloudHandoff } from "@/lib/oauthHandoff"; +import { PaperclipCloudOAuthHandoffPage } from "./PaperclipCloudOAuthHandoff"; + +const navigateTopLevel = vi.hoisted(() => vi.fn()); +const SESSION = "cloud_session_abcdefghijklmnop"; + +vi.mock("@/lib/browserNavigation", () => ({ + navigateTopLevel: (url: string) => navigateTopLevel(url), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + } +} + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + window.sessionStorage.clear(); + navigateTopLevel.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); +}); + +describe("PaperclipCloudOAuthHandoffPage", () => { + it("keeps a tenant loading state visible until the provider URL is ready", async () => { + savePendingCloudHandoff(SESSION); + let complete: ((response: Response) => void) | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise((resolve) => { + complete = resolve; + })); + + await act(async () => root.render()); + expect(container.textContent).toContain("Preparing secure sign-in"); + expect(navigateTopLevel).not.toHaveBeenCalled(); + + await act(async () => { + complete?.(Response.json({ authorizationUrl: "https://provider.example.test/authorize" })); + }); + await flushReact(); + + expect(navigateTopLevel).toHaveBeenCalledWith("https://provider.example.test/authorize"); + expect(window.sessionStorage.length).toBe(0); + }); + + it("keeps terminal handoff failures in Paperclip instead of opening confirmation", async () => { + savePendingCloudHandoff(SESSION); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ + error: "SESSION_NOT_AVAILABLE", + }, { status: 404 })); + + await act(async () => root.render()); + await flushReact(); + + expect(container.textContent).toContain("Sign-in couldn’t continue"); + expect(container.textContent).toContain("This sign-in expired. Start the connection again."); + expect(navigateTopLevel).not.toHaveBeenCalled(); + }); + + it("does not loop when recent-login recovery remains stale", async () => { + savePendingCloudHandoff(SESSION); + vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ + error: "RECENT_LOGIN_REQUIRED", + reauthenticationUrl: `${window.location.origin}/cloud/connections/reauth?session=${SESSION}`, + }, { status: 401 })); + + await act(async () => root.render()); + await flushReact(); + + expect(container.textContent).toContain("Paperclip couldn’t refresh this sign-in. Try again to continue."); + expect(navigateTopLevel).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/pages/apps/PaperclipCloudOAuthHandoff.tsx b/ui/src/pages/apps/PaperclipCloudOAuthHandoff.tsx new file mode 100644 index 0000000000..de1874ea18 --- /dev/null +++ b/ui/src/pages/apps/PaperclipCloudOAuthHandoff.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { navigateTopLevel } from "@/lib/browserNavigation"; +import { + clearPendingCloudHandoff, + prepareOAuthNavigation, + readPendingCloudHandoff, +} from "@/lib/oauthHandoff"; + +export type ManagedOAuthHandoffPhase = "loading" | "reauthenticating" | "error"; + +export function ManagedOAuthHandoffState({ + phase, + error, + onRetry, + onCancel, +}: { + phase: ManagedOAuthHandoffPhase; + error?: string | null; + onRetry: () => void; + onCancel: () => void; +}) { + const failed = phase === "error"; + return ( +
+
+ + {failed ? ( + + ) : ( + + )} + +
+

+ {failed ? "Sign-in couldn’t continue" : "Preparing secure sign-in"} +

+

+ {failed + ? error ?? "Paperclip couldn’t prepare the provider sign-in. Try again." + : phase === "reauthenticating" + ? "Your Paperclip sign-in is being refreshed." + : "Paperclip is opening the provider securely."} +

+ {failed ? ( +
+ + +
+ ) : null} +
+
+
+ ); +} + +/** Fixed tenant landing used only after Paperclip Cloud refreshes login. */ +export function PaperclipCloudOAuthHandoffPage() { + const [phase, setPhase] = useState("loading"); + const [error, setError] = useState(null); + + const resume = useCallback(async () => { + const handoff = readPendingCloudHandoff(); + if (!handoff) { + setPhase("error"); + setError("This sign-in expired. Return to Paperclip and start the connection again."); + return; + } + setPhase("loading"); + setError(null); + try { + const target = await prepareOAuthNavigation({ authorizationUrl: "", handoff }); + if (target.kind === "reauthentication") { + setPhase("error"); + setError("Paperclip couldn’t refresh this sign-in. Try again to continue."); + return; + } + clearPendingCloudHandoff(); + navigateTopLevel(target.url); + } catch (caught) { + setPhase("error"); + setError(caught instanceof Error ? caught.message : "Paperclip couldn’t prepare secure sign-in."); + } + }, []); + + useEffect(() => { + void resume(); + }, [resume]); + + return ( + void resume()} + onCancel={() => { + clearPendingCloudHandoff(); + navigateTopLevel("/"); + }} + /> + ); +} diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index 280a809be3..6679ba9418 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -19,8 +19,8 @@ import { Input } from "@/components/ui/input"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { useToast } from "@/context/ToastContext"; import { redactUrlSecrets } from "@/lib/redact-url-secrets"; -import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { cn } from "@/lib/utils"; import type { AppDetailSectionProps } from "./types"; import { RevokeGrantDialog } from "./IdentitiesSection"; @@ -158,15 +158,20 @@ export function ReconnectCard({ mutationFn: () => connection.credentialPolicy === "per_user" ? toolsApi.startOAuth(connection.id, { asCurrentUser: true }) : toolsApi.startOAuth(connection.id), - onSuccess: ({ authorizationUrl }) => { - // Reconnect navigates to the same discovered address a fresh connect does, - // so it goes through the same gate (PAP-17099). - const target = resolveAuthorizationTarget(authorizationUrl); - if (!target.ok) { - pushToast({ title: "Couldn’t start sign-in", body: target.message, tone: "error" }); - return; + onSuccess: async (start) => { + try { + const target = await prepareOAuthNavigation(start); + if (target.kind === "reauthentication" && start.handoff) { + savePendingCloudHandoff(start.handoff.session); + } + navigateTopLevel(target.url); + } catch (error) { + pushToast({ + title: "Couldn’t start sign-in", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); } - navigateTopLevel(target.url); }, onError: (error) => pushToast({ diff --git a/ui/src/pages/tools/PasteConfigTab.test.tsx b/ui/src/pages/tools/PasteConfigTab.test.tsx index 10e73e255c..2289ebdcf8 100644 --- a/ui/src/pages/tools/PasteConfigTab.test.tsx +++ b/ui/src/pages/tools/PasteConfigTab.test.tsx @@ -181,6 +181,7 @@ describe("PasteConfigTab — discoverability copy (PAP-11091)", () => { afterEach(() => { document.body.removeChild(container); document.body.innerHTML = ""; + vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -222,6 +223,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { afterEach(() => { document.body.removeChild(container); document.body.innerHTML = ""; + vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -374,6 +376,37 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => { expect(container.textContent).not.toContain("Review actions for notion"); }); + it("uses the background Cloud handoff returned with an imported OAuth connection", async () => { + const session = "imported_background_session_1234"; + const request = vi.spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ + authorizationUrl: "https://provider.example.test/authorize?state=imported", + })); + await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); + const result = oauthConnectResult("https://my.paperclip.app/connections/confirm?session=legacy"); + result.auth = { + ...result.auth!, + handoff: { kind: "paperclip_cloud", session }, + }; + toolsApiMock.connectApp.mockResolvedValue(result); + + await act(async () => { + buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(request).toHaveBeenCalledWith("/cloud/connections/handoff", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ session }), + })); + await vi.waitFor(() => { + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://provider.example.test/authorize?state=imported", + ); + }); + expect(navigateTopLevelMock).not.toHaveBeenCalledWith(expect.stringContaining("/connections/confirm")); + }); + it("rejects an unsafe start URL and retries OAuth on the same connection", async () => { await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG); toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)")); diff --git a/ui/src/pages/tools/PasteConfigTab.tsx b/ui/src/pages/tools/PasteConfigTab.tsx index 86b9eb91aa..9663116f36 100644 --- a/ui/src/pages/tools/PasteConfigTab.tsx +++ b/ui/src/pages/tools/PasteConfigTab.tsx @@ -7,14 +7,15 @@ import type { McpJsonImportDraft, McpJsonImportPreview, ToolAppConnectionActionSummary, + ToolOAuthStartResult, } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { toolsApi } from "@/api/tools"; -import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; import { useNavigate } from "@/lib/router"; import { OAuthConnectStateScreen, @@ -122,21 +123,27 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { }, }); - const openAuthorizationPage = (authorizationUrl: string) => { - const target = resolveAuthorizationTarget(authorizationUrl); - if (!target.ok) { + const openAuthorizationPage = async ( + authorizationUrl: string, + handoff?: ToolOAuthStartResult["handoff"], + ) => { + try { + const target = await prepareOAuthNavigation({ authorizationUrl, handoff }); + if (target.kind === "reauthentication" && handoff) { + savePendingCloudHandoff(handoff.session); + } + setAuthorizationHost(target.host); + setOAuthPhase(target.kind === "authorization" ? "redirecting" : "starting"); + navigateTopLevel(target.url); + } catch (error) { setOAuthPhase("error"); - setOAuthError(target.message); - return; + setOAuthError(error instanceof Error ? error.message : "Paperclip couldn’t start secure sign-in. Try again."); } - setAuthorizationHost(target.host); - setOAuthPhase("redirecting"); - navigateTopLevel(target.url); }; const oauthStartMutation = useMutation({ mutationFn: (connectionId: string) => toolsApi.startOAuth(connectionId), - onSuccess: ({ authorizationUrl }) => openAuthorizationPage(authorizationUrl), + onSuccess: (start) => void openAuthorizationPage(start.authorizationUrl, start.handoff), onError: (error) => { setOAuthPhase("error"); setOAuthError( @@ -172,7 +179,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) { } const startUrl = result.auth.startUrl?.trim(); if (startUrl) { - openAuthorizationPage(startUrl); + void openAuthorizationPage(startUrl, result.auth.handoff); } else { setOAuthPhase("starting"); oauthStartMutation.mutate(result.connectionId); diff --git a/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx b/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx new file mode 100644 index 0000000000..110597cc6a --- /dev/null +++ b/ui/storybook/stories/managed-cloud-oauth-handoff.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ManagedOAuthHandoffState } from "@/pages/apps/PaperclipCloudOAuthHandoff"; + +const meta: Meta = { + title: "Apps/Managed Cloud OAuth handoff", + component: ManagedOAuthHandoffState, + parameters: { layout: "fullscreen" }, + args: { + onRetry: () => undefined, + onCancel: () => undefined, + }, +}; + +export default meta; +type Story = StoryObj; + +export const PreparingProvider: Story = { + args: { phase: "loading" }, +}; + +export const ResumingAfterReauthentication: Story = { + args: { phase: "reauthenticating" }, +}; + +export const RetryAfterInfrastructureFailure: Story = { + args: { + phase: "error", + error: "Paperclip Cloud couldn’t prepare secure sign-in. Try again.", + }, +}; + +export const TerminalExpiredSession: Story = { + args: { + phase: "error", + error: "This sign-in expired. Return to Paperclip and start the connection again.", + }, +}; From 112ef5beecf518ce9e0cbbead3eac297c09fc775 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:39:00 -0700 Subject: [PATCH 2/2] chore(deps): bump i18next from 26.3.6 to 26.4.0 (#12267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [i18next](https://github.com/i18next/i18next) from 26.3.6 to 26.4.0.
Release notes

Sourced from i18next's releases.

v26.4.0

  • perf: cache toResolveHierarchy results per (code, fallbackCode) pair. The hierarchy resolver runs on every t() call and calls Intl.getCanonicalLocales multiple times, which showed up prominently when profiling render-heavy UIs (e.g. virtualized data grids); with the cache the per-call cost drops from ~886 ns to ~41 ns. The cache is invalidated automatically when options.fallbackLng changes (reassignment or in-place array mutation); if you mutate other resolution-relevant options at runtime (load, lowerCaseLng, cleanCode, nonExplicitSupportedLngs), call i18next.services.languageUtils.clearCache() afterwards. Function-valued fallbackLng and per-call array/object fallbackLng options are never cached, so dynamic fallbacks keep working as before. Thanks @​equaterina (#2444).
  • chore: update all devDependencies (Babel stays on 7.x until @rollup/plugin-babel supports 8, eslint on 9.x for neostandard). Removed the unused coveralls package (CI uses the Coveralls GitHub Action) and replaced sinon with nise + vitest.spyOn in the v1 compatibility tests, which resolves all open npm audit findings (0 vulnerabilities) and should close the dependabot alerts on the lockfile.
Changelog

Sourced from i18next's changelog.

26.4.0

  • perf: cache toResolveHierarchy results per (code, fallbackCode) pair. The hierarchy resolver runs on every t() call and calls Intl.getCanonicalLocales multiple times, which showed up prominently when profiling render-heavy UIs (e.g. virtualized data grids); with the cache the per-call cost drops from ~886 ns to ~41 ns. The cache is invalidated automatically when options.fallbackLng changes (reassignment or in-place array mutation); if you mutate other resolution-relevant options at runtime (load, lowerCaseLng, cleanCode, nonExplicitSupportedLngs), call i18next.services.languageUtils.clearCache() afterwards. Function-valued fallbackLng and per-call array/object fallbackLng options are never cached, so dynamic fallbacks keep working as before. Thanks @​equaterina (#2444).
  • chore: update all devDependencies (Babel stays on 7.x until @rollup/plugin-babel supports 8, eslint on 9.x for neostandard). Removed the unused coveralls package (CI uses the Coveralls GitHub Action) and replaced sinon with nise + vitest.spyOn in the v1 compatibility tests, which resolves all open npm audit findings (0 vulnerabilities) and should close the dependabot alerts on the lockfile.
Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 16 ++++++++-------- ui/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cef74d885e..8a847c7d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1093,8 +1093,8 @@ importers: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) i18next: - specifier: ^26.3.6 - version: 26.3.6(typescript@7.0.2) + specifier: ^26.4.0 + version: 26.4.0(typescript@7.0.2) lexical: specifier: 0.48.0 version: 0.48.0(typescript@7.0.2) @@ -1118,7 +1118,7 @@ importers: version: 19.2.8(react@19.2.8) react-i18next: specifier: ^17.0.11 - version: 17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) + version: 17.0.11(i18next@26.4.0(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.18)(react@19.2.8) @@ -6426,8 +6426,8 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - i18next@26.3.6: - resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + i18next@26.4.0: + resolution: {integrity: sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==} peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: @@ -13734,7 +13734,7 @@ snapshots: human-signals@2.1.0: {} - i18next@26.3.6(typescript@7.0.2): + i18next@26.4.0(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 @@ -15101,11 +15101,11 @@ snapshots: dependencies: react: 19.2.8 - react-i18next@17.0.11(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): + react-i18next@17.0.11(i18next@26.4.0(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 - i18next: 26.3.6(typescript@7.0.2) + i18next: 26.4.0(typescript@7.0.2) react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: diff --git a/ui/package.json b/ui/package.json index f13941b8b4..01fef4028b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -57,7 +57,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "i18next": "^26.3.6", + "i18next": "^26.4.0", "lexical": "0.48.0", "lucide-react": "^1.38.0", "mermaid": "^11.17.2",