fix(auth): avoid CSRF challenges during OTP login
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user