paperclip/ui/src/lib/oauthHandoff.ts

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);
}