Files
PaperClipAI/ui/src/lib/oauthHandoff.ts
T
DottaandPaperclip 5f87090894 Make managed Cloud OAuth handoffs invisible (#12790)
## 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>
2026-09-03 16:33:13 -05:00

181 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}