From 142640d96c4f488166d36a09b9e45a1a00c5cc39 Mon Sep 17 00:00:00 2001 From: Ivan Pereira <183991+ivanrvpereira@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:33:40 +0100 Subject: [PATCH] fix(auth): avoid CSRF challenges during OTP login --- CHANGELOG.md | 7 +++ README.md | 2 +- package.json | 2 +- src/auth/login.ts | 52 +++++++++------ src/perplexity-fetch.ts | 136 ++++++++++++++++++++++++++++++++++++++++ src/search/client.ts | 120 +++++------------------------------ 6 files changed, 195 insertions(+), 124 deletions(-) create mode 100644 src/perplexity-fetch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfb6b1..259e790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.2] - 2026-06-22 + +### Fixed + +- Route email OTP authentication requests through the Bun-backed Perplexity fetch path to avoid CSRF 403 failures under Node/jiti. +- Send browser-like auth headers and include response previews in OTP HTTP errors. + ## [0.2.1] - 2026-05-11 ### Changed diff --git a/README.md b/README.md index 6838967..26d3704 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Queries default to `is_incognito: true`, but you can override that per call or v The extension calls Perplexity's internal SSE endpoint (`perplexity_ask`) using your subscription credentials obtained from the macOS app or via email OTP. Responses stream as incremental events that are merged into a final result. -When pi loads extensions under Node/jiti, direct `fetch` to Perplexity gets Cloudflare-challenged, so the search client shells out to a Bun subprocess — that's the only reason Bun is required. +When pi loads extensions under Node/jiti, direct `fetch` to Perplexity can get Cloudflare-challenged, so Perplexity network calls shell out to a Bun subprocess — that's the only reason Bun is required. ## Development diff --git a/package.json b/package.json index 5a97152..481b901 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-perplexity", - "version": "0.2.1", + "version": "0.2.2", "description": "Perplexity web search for pi — uses your Pro/Max subscription, no API credits needed", "keywords": [ "pi-package", diff --git a/src/auth/login.ts b/src/auth/login.ts index 3cbce3a..bb07153 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -5,6 +5,10 @@ import { AuthError } from "../search/types.js"; import { errorMessage } from "../render/util.js"; import { loadToken, saveToken } from "./storage.js"; import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js"; +import { + perplexityFetchText as fetchAuth, + type PerplexityFetchResponse as AuthFetchResponse, +} from "../perplexity-fetch.js"; const DESKTOP_AUTH_HELP = "Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing."; @@ -29,11 +33,32 @@ function buildAuthHeaders(includeJsonContentType = false): Record cookie.split(";")[0]).join("; "); +} + function extractTokenFromPayload(payload: unknown): string | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; const obj = payload as Record; @@ -46,31 +71,23 @@ function extractTokenFromPayload(payload: unknown): string | null { return null; } -async function readJsonResponse(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - async function loginWithEmailOtp( email: string, options: AuthenticateOptions, ): Promise { const signal = options.signal ?? null; - const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, { + const csrfResponse = await fetchAuth(`${AUTH_BASE_URL}/csrf`, { method: "GET", headers: buildAuthHeaders(), signal, }); if (!csrfResponse.ok) { - throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`); + throw new Error(formatHttpFailure("Failed to fetch CSRF token", csrfResponse)); } - const csrfPayload = (await readJsonResponse(csrfResponse)) as { csrfToken?: unknown } | null; + const csrfPayload = parseJsonResponse(csrfResponse) as { csrfToken?: unknown } | null; const csrfToken = csrfPayload && typeof csrfPayload.csrfToken === "string" ? csrfPayload.csrfToken : null; @@ -78,15 +95,14 @@ async function loginWithEmailOtp( throw new Error("CSRF token missing from Perplexity auth response."); } - const cookies = csrfResponse.headers.getSetCookie?.() ?? []; - const cookieHeader = cookies.map((c) => c.split(";")[0]).join("; "); + const cookieHeader = cookieHeaderFrom(csrfResponse.cookies); const emailHeaders = buildAuthHeaders(true); if (cookieHeader) { emailHeaders.Cookie = cookieHeader; } - const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, { + const emailResponse = await fetchAuth(`${AUTH_BASE_URL}/signin-email`, { method: "POST", headers: emailHeaders, body: JSON.stringify({ email, csrfToken }), @@ -94,7 +110,7 @@ async function loginWithEmailOtp( }); if (!emailResponse.ok) { - throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`); + throw new Error(formatHttpFailure("Failed to send OTP email", emailResponse)); } const otp = @@ -113,7 +129,7 @@ async function loginWithEmailOtp( otpHeaders.Cookie = cookieHeader; } - const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, { + const otpResponse = await fetchAuth(`${AUTH_BASE_URL}/signin-otp`, { method: "POST", headers: otpHeaders, body: JSON.stringify({ email, otp, csrfToken }), @@ -121,10 +137,10 @@ async function loginWithEmailOtp( }); if (!otpResponse.ok) { - throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`); + throw new Error(formatHttpFailure("OTP verification failed", otpResponse)); } - const otpPayload = await readJsonResponse(otpResponse); + const otpPayload = parseJsonResponse(otpResponse); const token = extractTokenFromPayload(otpPayload); if (!token) { throw new Error("Perplexity OTP response did not include a token."); diff --git a/src/perplexity-fetch.ts b/src/perplexity-fetch.ts new file mode 100644 index 0000000..f447a2b --- /dev/null +++ b/src/perplexity-fetch.ts @@ -0,0 +1,136 @@ +const MAX_BUN_STDOUT = 50 * 1024 * 1024; + +export interface PerplexityFetchOptions { + method: "GET" | "POST"; + headers: Record; + body?: string; + signal?: AbortSignal | null; +} + +export interface PerplexityFetchResponse { + status: number; + ok: boolean; + bodyText: string; + cookies: string[]; +} + +/** + * Perplexity's Cloudflare edge can challenge Node/jiti fetch. + * Use native fetch under Bun (tests/direct scripts), otherwise shell out to Bun for its TLS fingerprint. + */ +export async function perplexityFetchText( + url: string, + options: PerplexityFetchOptions, +): Promise { + if (typeof Bun === "undefined") { + return fetchViaBunRuntime(url, options); + } + + const fetchOptions: RequestInit = { + method: options.method, + headers: options.headers, + signal: options.signal ?? null, + }; + if (options.body !== undefined) { + fetchOptions.body = options.body; + } + + const response = await fetch(url, fetchOptions); + + return { + status: response.status, + ok: response.ok, + bodyText: await response.text(), + cookies: response.headers.getSetCookie?.() ?? [], + }; +} + +async function fetchViaBunRuntime( + url: string, + options: PerplexityFetchOptions, +): Promise { + const script = ` +const c = JSON.parse(await Bun.stdin.text()); +try { + const r = await fetch(c.url, { + method: c.method, + headers: c.headers, + body: c.body, + }); + const t = await r.text(); + process.stdout.write(JSON.stringify({ + s: r.status, + b: t, + c: r.headers.getSetCookie?.() ?? [], + })); +} catch (e) { + process.stdout.write(JSON.stringify({ s: 0, b: String(e?.message ?? e), c: [] })); +} +`; + + const { spawn } = await import("node:child_process"); + + const stdout = await new Promise((resolve, reject) => { + const child = spawn("bun", ["-e", script], { + stdio: ["pipe", "pipe", "ignore"], + env: { HOME: process.env.HOME, PATH: process.env.PATH }, + }); + + const onAbort = () => child.kill(); + if (options.signal) { + options.signal.addEventListener("abort", onAbort, { once: true }); + child.on("close", () => options.signal?.removeEventListener("abort", onAbort)); + } + + if (!child.stdin || !child.stdout) { + reject(new Error("Failed to open subprocess pipes")); + return; + } + + child.stdin.write( + JSON.stringify({ + url, + method: options.method, + headers: options.headers, + body: options.body, + }), + ); + child.stdin.end(); + + const chunks: Buffer[] = []; + let totalLen = 0; + child.stdout.on("data", (chunk: Buffer) => { + totalLen += chunk.length; + if (totalLen <= MAX_BUN_STDOUT) { + chunks.push(chunk); + } + }); + + child.on("close", () => resolve(Buffer.concat(chunks).toString("utf8"))); + child.on("error", reject); + }); + + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`Bun subprocess returned invalid output: ${stdout.slice(0, 200)}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Bun subprocess response is not an object."); + } + + const obj = parsed as Record; + if (typeof obj.s !== "number" || typeof obj.b !== "string" || !Array.isArray(obj.c)) { + throw new Error("Bun subprocess response missing required fields."); + } + + const cookies = obj.c.filter((cookie): cookie is string => typeof cookie === "string"); + return { + status: obj.s, + ok: obj.s >= 200 && obj.s < 300, + bodyText: obj.b, + cookies, + }; +} diff --git a/src/search/client.ts b/src/search/client.ts index ed81d95..1f6ff19 100644 --- a/src/search/client.ts +++ b/src/search/client.ts @@ -3,10 +3,10 @@ import type { SearchResult, StreamEvent, WebResult } from "./types.js"; import { SearchError } from "./types.js"; import { errorMessage } from "../render/util.js"; import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js"; +import { perplexityFetchText } from "../perplexity-fetch.js"; const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask"; - function streamFromText(text: string): ReadableStream { const bytes = new TextEncoder().encode(text); return new ReadableStream({ @@ -17,90 +17,6 @@ function streamFromText(text: string): ReadableStream { }); } -const MAX_BUN_STDOUT = 50 * 1024 * 1024; - -/** - * Execute a Perplexity request via a Bun subprocess. - * Pi loads extensions under Node/jiti whose fetch gets Cloudflare-challenged. - * Bun's native fetch has a different TLS fingerprint that passes. - */ -async function fetchViaBunRuntime( - url: string, - headers: Record, - body: string, - signal?: AbortSignal, -): Promise<{ status: number; bodyText: string }> { - const script = ` -const c = JSON.parse(await Bun.stdin.text()); -try { - const r = await fetch(c.url, { method: "POST", headers: c.headers, body: c.body }); - const t = await r.text(); - process.stdout.write(JSON.stringify({ s: r.status, b: t })); -} catch (e) { - process.stdout.write(JSON.stringify({ s: 0, b: String(e?.message ?? e) })); -} -`; - - // Dynamic import: spawn is only needed under Node/jiti (not Bun), - // and Bun's node:child_process polyfill may not export it. - const { spawn } = await import("node:child_process"); - - const stdout = await new Promise((resolve, reject) => { - const child = spawn("bun", ["-e", script], { - stdio: ["pipe", "pipe", "ignore"], - env: { HOME: process.env.HOME, PATH: process.env.PATH }, - }); - - if (signal) { - const onAbort = () => child.kill(); - signal.addEventListener("abort", onAbort, { once: true }); - child.on("close", () => signal.removeEventListener("abort", onAbort)); - } - - if (!child.stdin || !child.stdout) { - reject(new Error("Failed to open subprocess pipes")); - return; - } - - child.stdin.write(JSON.stringify({ url, headers, body })); - child.stdin.end(); - - const chunks: Buffer[] = []; - let totalLen = 0; - child.stdout.on("data", (chunk: Buffer) => { - totalLen += chunk.length; - if (totalLen <= MAX_BUN_STDOUT) { - chunks.push(chunk); - } - }); - - child.on("close", () => resolve(Buffer.concat(chunks).toString("utf8"))); - child.on("error", reject); - }); - - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { - throw new Error(`Bun subprocess returned invalid output: ${stdout.slice(0, 200)}`); - } - - if ( - !parsed || - typeof parsed !== "object" || - Array.isArray(parsed) - ) { - throw new Error("Bun subprocess response is not an object."); - } - - const obj = parsed as Record; - if (typeof obj.s !== "number" || typeof obj.b !== "string") { - throw new Error("Bun subprocess response missing required fields."); - } - - return { status: obj.s, bodyText: obj.b }; -} - export interface SearchParams { query: string; recency?: "hour" | "day" | "week" | "month" | "year"; @@ -274,20 +190,15 @@ export async function searchPerplexity( let eventStream: ReadableStream; - // Bun's native fetch passes Cloudflare; Node/jiti's fetch gets challenged. - // Use native fetch when running under Bun (tests, direct scripts), - // subprocess fallback when running under Node/jiti (pi extension runtime). - const useBunSubprocess = typeof Bun === "undefined"; - - if (useBunSubprocess) { - let bunResult: { status: number; bodyText: string }; + if (typeof Bun === "undefined") { + let response: { status: number; bodyText: string }; try { - bunResult = await fetchViaBunRuntime( - PERPLEXITY_ENDPOINT, - requestHeaders, - JSON.stringify(requestBody), - signal, - ); + response = await perplexityFetchText(PERPLEXITY_ENDPOINT, { + method: "POST", + headers: requestHeaders, + body: JSON.stringify(requestBody), + signal: signal ?? null, + }); } catch (error) { if (signal?.aborted) { throw new SearchError("NETWORK", "Perplexity request was cancelled."); @@ -298,17 +209,18 @@ export async function searchPerplexity( `Could not connect to Perplexity. ${errorMessage(error)}`, ); } - if (bunResult.status === 0) { - throw new SearchError("NETWORK", bunResult.bodyText); + + if (response.status === 0) { + throw new SearchError("NETWORK", response.bodyText); } - if (bunResult.status !== 200) { - throw mapHttpError(bunResult.status); + if (response.status !== 200) { + throw mapHttpError(response.status); } - if (!bunResult.bodyText) { + if (!response.bodyText) { throw new SearchError("STREAM", "Perplexity returned an empty response."); } - eventStream = streamFromText(bunResult.bodyText); + eventStream = streamFromText(response.bodyText); } else { let response: Response; try {