fix(connections): reuse cloud enrollment for oauth

This commit is contained in:
Dotta 2026-09-05 07:39:20 -05:00
parent 8430bd897f
commit 762dbe4d7e
4 changed files with 251 additions and 70 deletions

View File

@ -93,6 +93,54 @@ describe("Paperclip Cloud connector", () => {
});
});
it("prefers a validated HTTPS provider URL over the legacy confirmation URL", async () => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(connector.startAuthorization({
subject,
companyId,
profile: "github.code",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-direct",
})).resolves.toMatchObject({
authorizationUrl: "https://github.com/login/oauth/authorize?client_id=client&state=broker-state",
});
});
it.each([
["non-string", { href: "https://github.com/login/oauth/authorize" }],
["plaintext HTTP", "http://github.com/login/oauth/authorize"],
["embedded credentials", "https://user:password@github.com/login/oauth/authorize"],
["fragment", "https://github.com/login/oauth/authorize#unexpected"],
["not a URL", "not-a-url"],
])("rejects a malformed direct provider URL: %s", async (_label, authorizationUrl) => {
const keys = config();
const connector = createPaperclipCloudConnector({
config: keys.config,
request: vi.fn(async () => Response.json({
confirmationUrl: "https://my.example.test/connections/confirm?session=broker-state",
authorizationUrl,
expiresAt: "2099-08-21T20:00:00.000Z",
})) as typeof fetch,
});
await expect(connector.startAuthorization({
subject,
companyId,
profile: "github.code",
returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback",
returnState: "state-malformed-direct",
})).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" });
});
it("binds active GitHub installations to proof from the current user token", async () => {
const keys = config();
const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {

View File

@ -93,6 +93,7 @@ type SealedEnvelope = {
type ConnectorResponse = {
confirmationUrl?: unknown;
authorizationUrl?: unknown;
handoff?: unknown;
expiresAt?: unknown;
scopes?: unknown;
@ -348,14 +349,44 @@ export function createPaperclipCloudConnector(input: {
if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid session", "CONNECTOR_BAD_RESPONSE");
}
const confirmationUrl = new URL(response.confirmationUrl);
const expectedBroker = new URL(config.baseUrl);
if (confirmationUrl.origin !== expectedBroker.origin || confirmationUrl.pathname !== "/connections/confirm") {
let confirmationUrl: URL;
try {
confirmationUrl = new URL(response.confirmationUrl);
} catch {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
const expectedBroker = new URL(config.baseUrl);
if (
confirmationUrl.origin !== expectedBroker.origin
|| confirmationUrl.pathname !== "/connections/confirm"
|| confirmationUrl.username
|| confirmationUrl.password
|| confirmationUrl.hash
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid confirmation URL", "CONNECTOR_BAD_RESPONSE");
}
let authorizationUrl: URL | undefined;
if (response.authorizationUrl !== undefined) {
if (typeof response.authorizationUrl !== "string") {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
try {
authorizationUrl = new URL(response.authorizationUrl);
} catch {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
if (
authorizationUrl.protocol !== "https:"
|| authorizationUrl.username
|| authorizationUrl.password
|| authorizationUrl.hash
) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid provider URL", "CONNECTOR_BAD_RESPONSE");
}
}
const handoff = parseCloudHandoff(response.handoff);
return {
authorizationUrl: confirmationUrl.toString(),
authorizationUrl: authorizationUrl?.toString() ?? confirmationUrl.toString(),
expiresAt: response.expiresAt,
...(handoff ? { handoff } : {}),
};

View File

@ -124,6 +124,19 @@ const ROUTE_STAGE_BY_STEP: Partial<Record<Step, string>> = {
success: "complete",
};
export function requestedConnectionInitialStep(input: {
requestedAppKey: string | undefined;
routeStage: string | null;
resumeConnectionId: string | null;
hasPrefilledLink: boolean;
zapierSource: boolean;
}): Step {
if (input.requestedAppKey) {
return input.resumeConnectionId || input.routeStage === "setup" ? "key" : "access";
}
return input.hasPrefilledLink || input.zapierSource ? "access" : "gallery";
}
export function requestedConnectionEntry(input: {
requestedAppKey: string;
galleryApps: readonly AppDefinition[];
@ -440,6 +453,7 @@ export function ConnectionSetupFlow({
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
const sourceSlug = searchParams.get("source")?.trim() || null;
const createNewConnection = searchParams.get("new") === "1";
const routeStage = searchParams.get("stage")?.trim() || null;
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
const oauthCallbackOutcome = searchParams.get("oauth");
const oauthCallbackCode = searchParams.get("code");
@ -476,11 +490,13 @@ export function ConnectionSetupFlow({
};
});
const [step, setStep] = useState<Step>(
requestedAppKey
? resumeConnectionId ? "key" : "access"
: prefill.link || zapierSource ? "access" : "gallery",
);
const [step, setStep] = useState<Step>(() => requestedConnectionInitialStep({
requestedAppKey,
routeStage,
resumeConnectionId,
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
const [entry, setEntry] = useState<AppDefinition | null>(null);
const [galleryName, setGalleryName] = useState("");
const [linkUrl, setLinkUrl] = useState(prefill.link);
@ -1170,7 +1186,13 @@ export function ConnectionSetupFlow({
// Route/service selection initializes the wizard once. Later renders must
// preserve the user's current step in both hosts instead of snapping back
// to Access after they continue.
setStep(resumeConnectionId ? "key" : "access");
setStep(requestedConnectionInitialStep({
requestedAppKey,
routeStage,
resumeConnectionId,
hasPrefilledLink: Boolean(prefill.link),
zapierSource,
}));
}
if (automaticOAuth && (
@ -1202,6 +1224,8 @@ export function ConnectionSetupFlow({
resumeConnectionId,
requestedAppKey,
requestedAgentId,
routeStage,
zapierSource,
]);
// Resume the exact method and non-secret provider configuration that the
@ -1781,43 +1805,48 @@ export function ConnectionSetupFlow({
{step === "key" && entry && showConnectorEnrollmentStep ? (
<div className="mx-auto max-w-xl">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
<Cloud className="h-5 w-5" />
<div className="rounded-xl border border-border bg-card p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-muted p-2 text-muted-foreground">
<Cloud className="h-5 w-5" />
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground">
Connect with Paperclip
</h2>
<p className="mt-2 text-sm text-muted-foreground">
You must connect this instance to Paperclip to connect to {entry.name} (you only need to do this once).
</p>
</div>
</div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground">
Connect with Paperclip
</h2>
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
</InlineBanner>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
Back
</Button>
<Button
type="button"
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
onClick={() => {
setConnectorEnrollmentError(null);
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) openConnectorEnrollment(verificationUrl);
else startConnectorEnrollment.mutate();
}}
>
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{connectorEnrollmentQuery.data?.status === "pending"
? "Continue"
: "Connect with Paperclip"}
</Button>
</div>
</div>
{connectorEnrollmentQuery.isError || connectorEnrollmentError ? (
<InlineBanner tone="danger" className="mt-4">
{connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."}
</InlineBanner>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="ghost" onClick={() => setAppStep("access")}>
Back
</Button>
<Button
type="button"
disabled={connectorEnrollmentQuery.isLoading || startConnectorEnrollment.isPending}
onClick={() => {
setConnectorEnrollmentError(null);
const verificationUrl = connectorEnrollmentQuery.data?.verificationUrl;
if (verificationUrl) openConnectorEnrollment(verificationUrl);
else startConnectorEnrollment.mutate();
}}
>
{startConnectorEnrollment.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{connectorEnrollmentQuery.data?.status === "pending"
? "Continue"
: "Connect with Paperclip"}
</Button>
</div>
</div>
) : step === "key" && entry ? (
<KeyStep
@ -3236,7 +3265,9 @@ function KeyStep({
? "Checking…"
: usingVercel
? method?.auth === "oauth" ? "Validate and continue" : "Validate and connect"
: method?.auth === "oauth" ? "Continue to sign in" : "Connect"}
: method?.auth === "oauth"
? entry.slug === "github" ? "Continue to GitHub" : "Continue to sign in"
: "Connect"}
</Button>
</div>
</div>
@ -3449,7 +3480,7 @@ export function AccessStep({
queryFn: () => agentsApi.list(companyId),
});
const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated");
// "Just agents I pick" means agents this person may actually edit. When the server
// "Only agents I choose" / "Just agents I pick" means agents this person may actually edit. When the server
// has not told us, fall back to every live agent rather than an empty list —
// an empty picker would read as "you have no agents".
const editableAgentIds = capabilities?.editableAgentIds;
@ -3479,13 +3510,22 @@ export function AccessStep({
const lockedAgentName = lockedAgentId
? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent"
: null;
const identityHeading = githubIdentity ? "Connect GitHub as" : "Which humans can use this credential?";
const agentAccessHeading = grantKind === "agent"
? "Which agent owns this GitHub account?"
: githubIdentity && grantKind === "user"
? "Which agents may use your GitHub when you’re responsible?"
: githubIdentity
? "Which agents may use the shared GitHub account?"
: "Which agents can use this connection?";
const agentAccessLabel = githubIdentity ? agentAccessHeading : "Which agents can use this connection?";
return (
<div className="mx-auto max-w-2xl">
<div className="overflow-hidden rounded-xl border border-border">
<div className="divide-y divide-border">
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">{githubIdentity ? "Which GitHub identity should this use?" : "Which humans can use this credential?"}</h2>
<h2 className="text-sm font-semibold text-foreground">{identityHeading}</h2>
{identityLoading ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
<Skeleton className="h-20 w-full rounded-md" />
@ -3500,17 +3540,28 @@ export function AccessStep({
) : (
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user"
? githubIdentity ? "My GitHub account" : "Just me"
: allowedGrantKinds[0] === "agent"
? "A dedicated account for an agent"
: "Any human in the company"}
<div>
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user"
? githubIdentity ? "My GitHub account" : "Just me"
: allowedGrantKinds[0] === "agent"
? "A dedicated account for an agent"
: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}
</div>
{githubIdentity ? (
<p className="mt-1 text-xs text-muted-foreground">
{allowedGrantKinds[0] === "user"
? "Agents use it only for runs where you are the responsible person."
: allowedGrantKinds[0] === "agent"
? "That agent always uses this account, regardless of who starts the run."
: "Eligible agents use one shared credential, regardless of who starts the run."}
</p>
) : null}
</div>
</div>
) : needsIdentityChoice ? (
<RadioCardGroup
ariaLabel="Which humans can use this credential?"
ariaLabel={githubIdentity ? identityHeading : "Which humans can use this credential?"}
className="mt-4 sm:grid-cols-2"
value={grantKind}
onValueChange={(next) => {
@ -3525,20 +3576,29 @@ export function AccessStep({
{
value: "user",
title: githubIdentity ? "My GitHub account" : "Just me",
description: githubIdentity
? "Agents use it only for runs where you are the responsible person."
: undefined,
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
},
{
value: "agent",
title: "A dedicated account for an agent",
description: githubIdentity
? "That agent always uses this account, regardless of who starts the run."
: undefined,
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "organization",
title: "Any human in the company",
title: githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company",
description: githubIdentity
? "Eligible agents use one shared credential, regardless of who starts the run."
: undefined,
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
accessibleLabel: canCreateOrganizationGrant
? "Any human in the company"
: `Any human in the company. Unavailable: ${capabilities?.organizationGrantReason ??
? githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"
: `${githubIdentity ? "Shared company GitHub account (advanced)" : "Any human in the company"}. Unavailable: ${capabilities?.organizationGrantReason ??
"Only a connection manager can share this credential with the organization."}`,
tooltip: canCreateOrganizationGrant
? undefined
@ -3556,7 +3616,7 @@ export function AccessStep({
</section>
<section className="p-6">
<h2 className="text-sm font-semibold text-foreground">{grantKind === "agent" ? "Which agent owns this GitHub account?" : "Which agents can use this connection?"}</h2>
<h2 className="text-sm font-semibold text-foreground">{agentAccessHeading}</h2>
{preserveAgentAccess ? (
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
@ -3576,19 +3636,29 @@ export function AccessStep({
grantKind === "agent" ? (
<p className="mt-2 text-sm text-muted-foreground">Choose exactly one agent. This identity cannot be shared with other agents.</p>
) : <RadioCardGroup
ariaLabel="Which agents can use this connection?"
ariaLabel={agentAccessLabel}
className="mt-4 sm:grid-cols-2"
value={installChoice}
onValueChange={(next) => setInstallChoice(next as "specific" | "all")}
options={[
{
value: "specific",
title: "Just agents I pick",
title: githubIdentity ? "Only agents I choose" : "Just agents I pick",
description: githubIdentity
? grantKind === "user"
? "Only selected agents may use your GitHub when you’re responsible."
: "Only selected agents may use the shared account."
: undefined,
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "all",
title: "Any agent",
description: githubIdentity
? grantKind === "user"
? "Every agent may use your GitHub when you’re responsible."
: "Every agent may use the shared account."
: undefined,
icon: <BotGroupIcon />,
accessibleLabel: canSetCompanyInstall
? "Any agent"

View File

@ -348,8 +348,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).toContain("Connect GitHub as");
expect(container.textContent).toContain("Which agents may use your GitHub when you’re responsible?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
expect(container.textContent).not.toContain("Set the identity and agent reach first");
expect(container.textContent).not.toContain("Which humans can use this credential?");
@ -360,14 +360,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
const myAccount = radios.find((r) => r.textContent?.includes("My GitHub account"));
const dedicated = radios.find((r) => r.textContent?.includes("A dedicated account for an agent"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Only agents I choose"));
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
expect(myAccount).toBeTruthy();
expect(dedicated).toBeTruthy();
expect(myAccount?.textContent).toBe("My GitHub account");
expect(dedicated?.textContent).toBe("A dedicated account for an agent");
expect(agentsIPick?.textContent).toBe("Just agents I pick");
expect(anyAgent?.textContent).toBe("Any agent");
expect(myAccount?.textContent).toContain("Agents use it only for runs where you are the responsible person.");
expect(dedicated?.textContent).toContain("That agent always uses this account, regardless of who starts the run.");
expect(agentsIPick?.textContent).toContain("Only selected agents may use your GitHub when you’re responsible.");
expect(anyAgent?.textContent).toContain("Every agent may use your GitHub when you’re responsible.");
expect(myAccount?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(dedicated?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
@ -408,7 +408,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("Just agents I pick"))
.find((r) => r.textContent?.includes("Only agents I choose"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -703,6 +703,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await passAccessStep();
expect(container.textContent).toContain("Connect with Paperclip");
expect(container.textContent).toContain(
"You must connect this instance to Paperclip to connect to Gmail (you only need to do this once).",
);
expect(buttonByText("Connect with Paperclip")?.closest(".rounded-xl")?.classList.contains("border-border")).toBe(true);
expect(container.textContent).not.toContain("Required once for managed Google sign-in.");
expect(container.textContent).not.toContain("Your OAuth app");
@ -721,6 +725,34 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("restores the setup step after the one-time enrollment callback", async () => {
mockSearch.value = "source=github&stage=setup&cloud_connector=enrolled";
listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB_MANAGED] });
await render();
expect(container.textContent).toContain("Step 2 of 2");
expect(container.textContent).toContain("Continue to GitHub");
expect(container.textContent).not.toContain("Connect GitHub as");
expect(container.textContent).not.toContain("Connect with Paperclip");
});
it("never renders self-host enrollment when the connector identity is already active", async () => {
mockSearch.value = "source=gmail&stage=setup";
listGalleryMock.mockResolvedValueOnce({
apps: [{
...GMAIL,
methods: GMAIL.methods.filter((method) => !method.oauthStrategy),
ownershipAvailability: { platform_shared: false, customer: true, dcr: true },
}],
});
await render();
expect(container.textContent).not.toContain("You must connect this instance to Paperclip");
expect(container.textContent).not.toContain("Connect with Paperclip");
});
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
mockParams.appKey = "google-drive";
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
@ -844,7 +876,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// A deep-linked app lands on Access first: identity and reach are chosen
// before the credential (PAP-17835).
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Connect GitHub as");
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
@ -2199,7 +2231,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Which GitHub identity should this use?");
expect(container.textContent).toContain("Connect GitHub as");
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));