fix(auth): avoid CSRF challenges during OTP login

This commit is contained in:
Ivan Pereira
2026-06-22 11:33:40 +01:00
parent 4e77f38d5d
commit 142640d96c
6 changed files with 195 additions and 124 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+34 -18
View File
@@ -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<string, string
return {
Accept: "application/json",
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
Origin: "https://www.perplexity.ai",
Referer: "https://www.perplexity.ai/",
"User-Agent": PERPLEXITY_USER_AGENT,
"X-App-ApiClient": "default",
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
};
}
function parseJsonResponse(response: AuthFetchResponse): unknown {
try {
return JSON.parse(response.bodyText) as unknown;
} catch {
return null;
}
}
function formatHttpFailure(action: string, response: AuthFetchResponse): string {
const bodyPreview = response.bodyText.trim().replace(/\s+/g, " ").slice(0, 160);
const suffix = bodyPreview ? `: ${bodyPreview}` : "";
return `${action} (HTTP ${response.status}${suffix}).`;
}
function cookieHeaderFrom(cookies: string[]): string {
return cookies.map((cookie) => 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<string, unknown>;
@@ -46,31 +71,23 @@ function extractTokenFromPayload(payload: unknown): string | null {
return null;
}
async function readJsonResponse(response: Response): Promise<unknown> {
try {
return (await response.json()) as unknown;
} catch {
return null;
}
}
async function loginWithEmailOtp(
email: string,
options: AuthenticateOptions,
): Promise<string> {
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.");
+136
View File
@@ -0,0 +1,136 @@
const MAX_BUN_STDOUT = 50 * 1024 * 1024;
export interface PerplexityFetchOptions {
method: "GET" | "POST";
headers: Record<string, string>;
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<PerplexityFetchResponse> {
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<PerplexityFetchResponse> {
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<string>((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<string, unknown>;
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,
};
}
+16 -104
View File
@@ -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<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
@@ -17,90 +17,6 @@ function streamFromText(text: string): ReadableStream<Uint8Array> {
});
}
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<string, string>,
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<string>((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<string, unknown>;
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<Uint8Array>;
// 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 {