mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## 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 <noreply@paperclip.ing>
181 lines
6.5 KiB
TypeScript
181 lines
6.5 KiB
TypeScript
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);
|
||
}
|