Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity
* origin/master: chore(deps): bump i18next from 26.3.6 to 26.4.0 (#12267) Make managed Cloud OAuth handoffs invisible (#12790)
This commit is contained in:
commit
46eed91836
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 } : {}) });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<typeof createPaperclipCloudConnector>;
|
||||
export type PaperclipCloudGoogleWorkspaceConnector = PaperclipCloudConnector;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="oauth-handoff" element={<PaperclipCloudOAuthHandoffPage />} />
|
||||
<Route path="auth" element={<AuthPage />} />
|
||||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
|
||||
const oauthPopupRef = useRef<Window | null>(null);
|
||||
const oauthHandoffAbortRef = useRef<AbortController | null>(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<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
|
||||
) => {
|
||||
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")))();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<Response> {
|
||||
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<ToolOAuthStartResult, "authorizationUrl" | "handoff">,
|
||||
options: { signal?: AbortSignal; request?: typeof fetch } = {},
|
||||
): Promise<PreparedOAuthNavigation> {
|
||||
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<string, unknown> | 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<Storage, "setItem"> = 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<Storage, "getItem" | "removeItem"> = 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<PendingCloudHandoff>;
|
||||
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<Storage, "removeItem"> = window.sessionStorage,
|
||||
): void {
|
||||
storage.removeItem(PENDING_HANDOFF_KEY);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void>) {
|
||||
let result: void | Promise<void> = 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(<PaperclipCloudOAuthHandoffPage />));
|
||||
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(<PaperclipCloudOAuthHandoffPage />));
|
||||
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(<PaperclipCloudOAuthHandoffPage />));
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip couldn’t refresh this sign-in. Try again to continue.");
|
||||
expect(navigateTopLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="flex max-w-lg items-start gap-3">
|
||||
<span className="mt-1 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
|
||||
{failed ? (
|
||||
<Link2 className="h-5 w-5 text-destructive" />
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-bold tracking-tight">
|
||||
{failed ? "Sign-in couldn’t continue" : "Preparing secure sign-in"}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{failed ? (
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
<Button type="button" onClick={onRetry}>Try again</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>Return to Paperclip</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fixed tenant landing used only after Paperclip Cloud refreshes login. */
|
||||
export function PaperclipCloudOAuthHandoffPage() {
|
||||
const [phase, setPhase] = useState<ManagedOAuthHandoffPhase>("loading");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<ManagedOAuthHandoffState
|
||||
phase={phase}
|
||||
error={error}
|
||||
onRetry={() => void resume()}
|
||||
onCancel={() => {
|
||||
clearPendingCloudHandoff();
|
||||
navigateTopLevel("/");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ManagedOAuthHandoffState } from "@/pages/apps/PaperclipCloudOAuthHandoff";
|
||||
|
||||
const meta: Meta<typeof ManagedOAuthHandoffState> = {
|
||||
title: "Apps/Managed Cloud OAuth handoff",
|
||||
component: ManagedOAuthHandoffState,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
onRetry: () => undefined,
|
||||
onCancel: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ManagedOAuthHandoffState>;
|
||||
|
||||
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.",
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue