refactor(auth): separate browser credential parsing

This commit is contained in:
Ivan Pereira
2026-06-24 17:51:37 +01:00
parent 2b107f13d1
commit f8919e254c
5 changed files with 254 additions and 210 deletions
+202
View File
@@ -0,0 +1,202 @@
import type { StoredToken } from "../search/types.js";
export const BROWSER_AUTH_HELP =
"If direct OTP is blocked by Cloudflare, sign in at https://www.perplexity.ai in a browser, then run /perplexity-login --browser and paste the copied cURL command, the Cookie request header, or the __Secure-next-auth.session-token value.";
const SESSION_TOKEN_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"perplexity_jwt",
"pplx_jwt",
] as const;
function normalizeInput(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
}
}
return trimmed;
}
function stripBearerPrefix(value: string): string {
return value.replace(/^Bearer\s+/i, "").trim();
}
function looksLikeToken(value: string): boolean {
const token = stripBearerPrefix(value);
const parts = token.split(".");
return token.length >= 40 && token.startsWith("eyJ") && (parts.length === 3 || parts.length === 5);
}
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function looksLikeCurlCommand(input: string): boolean {
return /^\s*curl(?:\s|$)/i.test(input);
}
function curlCommandHasCookieSource(input: string): boolean {
return (
/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie\s*:/i.test(input) ||
/(?:^|\s)(?:-b|--cookie)(?:=|\s+)/i.test(input)
);
}
function extractCookieHeader(input: string): string {
const trimmed = stripMatchingQuotes(input);
const curlHeader = trimmed.match(/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie:\s*([\s\S]*?)\1/i)?.[2];
if (curlHeader) {
return curlHeader.trim();
}
const quotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)(?:\s+|=)\$?(['"])([\s\S]*?)\1/i)?.[2];
if (quotedCurlCookieOption) {
return quotedCurlCookieOption.trim();
}
const unquotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)(?:=|\s+)([^\s\\]+)/i)?.[1];
if (unquotedCurlCookieOption) {
return unquotedCurlCookieOption.trim();
}
const cookieLine = trimmed.split(/\r?\n/).find((line) => /^\s*Cookie\s*:/i.test(line));
const candidate = cookieLine ?? trimmed;
return stripMatchingQuotes(candidate.replace(/^\s*Cookie\s*:\s*/i, ""));
}
function parseCookieHeader(cookieHeader: string): Map<string, string> {
const cookies = new Map<string, string>();
for (const part of cookieHeader.split(";")) {
const separator = part.indexOf("=");
if (separator < 0) {
continue;
}
const name = part.slice(0, separator).trim();
const value = stripMatchingQuotes(part.slice(separator + 1).trim());
if (name && value) {
cookies.set(name, value);
}
}
return cookies;
}
function cookieValueFromChunks(cookies: Map<string, string>, name: string): string | null {
const direct = cookies.get(name);
if (direct) {
return direct;
}
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const chunk = cookies.get(`${name}.${index}`);
if (!chunk) {
break;
}
chunks.push(chunk);
}
return chunks.length > 0 ? chunks.join("") : null;
}
export function extractSessionTokenFromCookieHeader(cookieHeader: string): string | null {
const cookies = parseCookieHeader(cookieHeader);
for (const name of SESSION_TOKEN_COOKIE_NAMES) {
const value = cookieValueFromChunks(cookies, name);
const token = normalizeInput(value ? decodeCookieValue(value) : null);
if (token) {
return token;
}
}
return null;
}
function credentialsFromCookieValue(value: string): StoredToken | null {
const cookies = normalizeInput(extractCookieHeader(value));
if (!cookies || !cookies.includes("=")) {
return null;
}
const credentials: StoredToken = { type: "oauth", cookies };
const sessionToken = extractSessionTokenFromCookieHeader(cookies);
if (sessionToken && looksLikeToken(sessionToken)) {
credentials.access = sessionToken;
}
return credentials;
}
export function parseBrowserAuthInput(input: string): StoredToken | null {
const normalized = normalizeInput(input);
if (!normalized) {
return null;
}
const bearerToken = normalizeInput(stripBearerPrefix(normalized));
if (bearerToken && looksLikeToken(bearerToken)) {
return { type: "oauth", access: bearerToken };
}
const cookieCredentials = credentialsFromCookieValue(normalized);
const cookies = cookieCredentials?.cookies;
if (cookieCredentials && cookies && cookies.includes("=")) {
const hasKnownSessionToken = Boolean(extractSessionTokenFromCookieHeader(cookies));
if (hasKnownSessionToken) {
return cookieCredentials;
}
}
return null;
}
export function browserAuthFailureMessage(input: string): string {
const normalized = normalizeInput(input) ?? "";
if (looksLikeCurlCommand(normalized) && !curlCommandHasCookieSource(normalized)) {
return [
"The cURL command you pasted does not include cookies, so it cannot be used for login.",
"Copy a signed-in Perplexity request whose cURL contains `-b ...`, `--cookie ...`, or `-H 'Cookie: ...'`.",
"In DevTools → Network, reload Perplexity or ask a question, then right-click a `www.perplexity.ai` request such as `perplexity_ask` → Copy → Copy as cURL.",
"Make sure the copied text contains `__Secure-next-auth.session-token` and ideally `cf_clearance`.",
].join(" ");
}
const cookies = normalizeInput(extractCookieHeader(normalized));
if (cookies && cookies.includes("=") && !extractSessionTokenFromCookieHeader(cookies)) {
return [
"I found cookies in the pasted value, but not a Perplexity signed-in session cookie.",
"Make sure you are signed in at https://www.perplexity.ai, then copy a request whose cookies include `__Secure-next-auth.session-token`.",
"Copy as cURL from a `perplexity_ask` request usually works best.",
].join(" ");
}
return `Could not find a Perplexity session token in the pasted browser auth value. ${BROWSER_AUTH_HELP}`;
}
export function browserLoginInstructions(): string {
return [
"Browser login:",
"1. Open https://www.perplexity.ai and sign in.",
"2. Open DevTools → Network.",
"3. Reload the page or ask one Perplexity question.",
"4. Right-click a www.perplexity.ai request → Copy → Copy as cURL.",
"5. Paste the copied cURL command here.",
"",
"The copied text must include -b, --cookie, or Cookie:, and should include __Secure-next-auth.session-token.",
"If it does not, copy a different www.perplexity.ai request, preferably perplexity_ask.",
"Alternatives: paste the request Cookie header, or paste the __Secure-next-auth.session-token value.",
].join("\n");
}
+8 -175
View File
@@ -4,27 +4,27 @@ import { promisify } from "node:util";
import { AuthError, type StoredToken } from "../search/types.js";
import { errorMessage } from "../render/util.js";
import { loadToken, saveToken } from "./storage.js";
import {
BROWSER_AUTH_HELP,
browserAuthFailureMessage,
extractSessionTokenFromCookieHeader,
parseBrowserAuthInput,
} from "./browser.js";
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
import {
perplexityFetchText as fetchAuth,
type PerplexityFetchResponse as AuthFetchResponse,
} from "../perplexity-fetch.js";
export { parseBrowserAuthInput } from "./browser.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.";
const OTP_AUTH_HELP =
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
const BROWSER_AUTH_HELP =
"If direct OTP is blocked by Cloudflare, sign in at https://www.perplexity.ai in a browser, then run /perplexity-login --browser and paste the copied cURL command, the Cookie request header, or the __Secure-next-auth.session-token value.";
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
const TOKEN_ENV_KEYS = ["PI_PERPLEXITY_TOKEN", "PI_PERPLEXITY_AUTH_TOKEN"] as const;
const COOKIE_ENV_KEYS = ["PI_PERPLEXITY_COOKIE", "PI_PERPLEXITY_COOKIES"] as const;
const SESSION_TOKEN_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
"perplexity_jwt",
"pplx_jwt",
] as const;
const execFileAsync = promisify(execFile);
@@ -47,182 +47,15 @@ function normalizeInput(value: string | null | undefined): string | null {
return trimmed ? trimmed : null;
}
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
}
}
return trimmed;
}
function stripBearerPrefix(value: string): string {
return value.replace(/^Bearer\s+/i, "").trim();
}
function looksLikeToken(value: string): boolean {
const token = stripBearerPrefix(value);
const parts = token.split(".");
return token.length >= 40 && token.startsWith("eyJ") && (parts.length === 3 || parts.length === 5);
}
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function looksLikeCurlCommand(input: string): boolean {
return /^\s*curl(?:\s|$)/i.test(input);
}
function curlCommandHasCookieSource(input: string): boolean {
return (
/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie\s*:/i.test(input) ||
/(?:^|\s)(?:-b|--cookie)(?:=|\s+)/i.test(input)
);
}
function extractCookieHeader(input: string): string {
const trimmed = stripMatchingQuotes(input);
const curlHeader = trimmed.match(/(?:^|\s)(?:-H|--header)(?:\s+|=)\$?(['"])Cookie:\s*([\s\S]*?)\1/i)?.[2];
if (curlHeader) {
return curlHeader.trim();
}
const quotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)(?:\s+|=)\$?(['"])([\s\S]*?)\1/i)?.[2];
if (quotedCurlCookieOption) {
return quotedCurlCookieOption.trim();
}
const unquotedCurlCookieOption = trimmed.match(/(?:^|\s)(?:-b|--cookie)(?:=|\s+)([^\s\\]+)/i)?.[1];
if (unquotedCurlCookieOption) {
return unquotedCurlCookieOption.trim();
}
const cookieLine = trimmed.split(/\r?\n/).find((line) => /^\s*Cookie\s*:/i.test(line));
const candidate = cookieLine ?? trimmed;
return stripMatchingQuotes(candidate.replace(/^\s*Cookie\s*:\s*/i, ""));
}
function parseCookieHeader(cookieHeader: string): Map<string, string> {
const cookies = new Map<string, string>();
for (const part of cookieHeader.split(";")) {
const separator = part.indexOf("=");
if (separator < 0) {
continue;
}
const name = part.slice(0, separator).trim();
const value = stripMatchingQuotes(part.slice(separator + 1).trim());
if (name && value) {
cookies.set(name, value);
}
}
return cookies;
}
function cookieValueFromChunks(cookies: Map<string, string>, name: string): string | null {
const direct = cookies.get(name);
if (direct) {
return direct;
}
const chunks: string[] = [];
for (let index = 0; ; index += 1) {
const chunk = cookies.get(`${name}.${index}`);
if (!chunk) {
break;
}
chunks.push(chunk);
}
return chunks.length > 0 ? chunks.join("") : null;
}
function extractSessionTokenFromCookieHeader(cookieHeader: string): string | null {
const cookies = parseCookieHeader(cookieHeader);
for (const name of SESSION_TOKEN_COOKIE_NAMES) {
const value = cookieValueFromChunks(cookies, name);
const token = normalizeInput(value ? decodeCookieValue(value) : null);
if (token) {
return token;
}
}
return null;
}
function credentialsFromTokenValue(value: string): StoredToken | null {
const token = normalizeInput(stripBearerPrefix(value));
return token ? { type: "oauth", access: token } : null;
}
function credentialsFromCookieValue(value: string): StoredToken | null {
const cookies = normalizeInput(extractCookieHeader(value));
if (!cookies || !cookies.includes("=")) {
return null;
}
const credentials: StoredToken = { type: "oauth", cookies };
const sessionToken = extractSessionTokenFromCookieHeader(cookies);
if (sessionToken && looksLikeToken(sessionToken)) {
credentials.access = sessionToken;
}
return credentials;
}
export function parseBrowserAuthInput(input: string): StoredToken | null {
const normalized = normalizeInput(input);
if (!normalized) {
return null;
}
const bearerToken = normalizeInput(stripBearerPrefix(normalized));
if (bearerToken && looksLikeToken(bearerToken)) {
return { type: "oauth", access: bearerToken };
}
const cookieCredentials = credentialsFromCookieValue(normalized);
const cookies = cookieCredentials?.cookies;
if (cookieCredentials && cookies && cookies.includes("=")) {
const hasKnownSessionToken = Boolean(extractSessionTokenFromCookieHeader(cookies));
if (hasKnownSessionToken) {
return cookieCredentials;
}
}
return null;
}
function browserAuthFailureMessage(input: string): string {
const normalized = normalizeInput(input) ?? "";
if (looksLikeCurlCommand(normalized) && !curlCommandHasCookieSource(normalized)) {
return [
"The cURL command you pasted does not include cookies, so it cannot be used for login.",
"Copy a signed-in Perplexity request whose cURL contains `-b ...`, `--cookie ...`, or `-H 'Cookie: ...'`.",
"In DevTools → Network, reload Perplexity or ask a question, then right-click a `www.perplexity.ai` request such as `perplexity_ask` → Copy → Copy as cURL.",
"Make sure the copied text contains `__Secure-next-auth.session-token` and ideally `cf_clearance`.",
].join(" ");
}
const cookies = normalizeInput(extractCookieHeader(normalized));
if (cookies && cookies.includes("=") && !extractSessionTokenFromCookieHeader(cookies)) {
return [
"I found cookies in the pasted value, but not a Perplexity signed-in session cookie.",
"Make sure you are signed in at https://www.perplexity.ai, then copy a request whose cookies include `__Secure-next-auth.session-token`.",
"Copy as cURL from a `perplexity_ask` request usually works best.",
].join(" ");
}
return `Could not find a Perplexity session token in the pasted browser auth value. ${BROWSER_AUTH_HELP}`;
}
function credentialsFromEnvironment(): StoredToken | null {
for (const key of TOKEN_ENV_KEYS) {
const value = normalizeInput(process.env[key]);
+1 -15
View File
@@ -1,5 +1,6 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { browserLoginInstructions } from "../auth/browser.js";
import { authenticate, saveBrowserAuthInput } from "../auth/login.js";
import { clearToken } from "../auth/storage.js";
import { AuthError } from "../search/types.js";
@@ -51,21 +52,6 @@ function usageText(): string {
return `Usage: /${LOGIN_COMMAND_NAME} [--force] [--browser]\n\nFlags:\n --force, --refresh, -f Clear cached token before login\n --browser, --cookie Import browser auth by pasting Copy as cURL, a Cookie header, or a session token\n --help, -h Show this help`;
}
function browserLoginInstructions(): string {
return [
"Browser login:",
"1. Open https://www.perplexity.ai and sign in.",
"2. Open DevTools → Network.",
"3. Reload the page or ask one Perplexity question.",
"4. Right-click a www.perplexity.ai request → Copy → Copy as cURL.",
"5. Paste the copied cURL command here.",
"",
"The copied text must include -b, --cookie, or Cookie:, and should include __Secure-next-auth.session-token.",
"If it does not, copy a different www.perplexity.ai request, preferably perplexity_ask.",
"Alternatives: paste the request Cookie header, or paste the __Secure-next-auth.session-token value.",
].join("\n");
}
export function registerPerplexityCommands(pi: ExtensionAPI): void {
pi.registerCommand(LOGIN_COMMAND_NAME, {
description: "Authenticate Perplexity and persist token",
+2 -20
View File
@@ -5,8 +5,8 @@ import { Type } from "@sinclair/typebox";
import { registerPerplexityConfigCommand } from "./commands/config.js";
import { registerPerplexityCommands } from "./commands/login.js";
import { browserLoginInstructions } from "./auth/browser.js";
import { authenticate } from "./auth/login.js";
import { clearToken } from "./auth/storage.js";
import { loadConfig, resolveSearchDefaults } from "./config.js";
import { formatForLLM } from "./search/format.js";
import { searchPerplexity } from "./search/client.js";
@@ -59,21 +59,7 @@ export default function (pi: ExtensionAPI) {
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
promptForBrowserAuth: async () => {
ctx?.ui?.notify?.(
[
"Browser login needed:",
"1. Open https://www.perplexity.ai and sign in.",
"2. Open DevTools → Network.",
"3. Reload the page or ask one Perplexity question.",
"4. Right-click a www.perplexity.ai request → Copy → Copy as cURL.",
"5. Paste the copied cURL command here.",
"",
"The copied text must include -b, --cookie, or Cookie:, and should include __Secure-next-auth.session-token.",
"If it does not, copy a different www.perplexity.ai request, preferably perplexity_ask.",
"Alternatives: paste the request Cookie header, or paste the __Secure-next-auth.session-token value.",
].join("\n"),
"info",
);
ctx?.ui?.notify?.(browserLoginInstructions(), "info");
return promptInput(
"Paste copied cURL command or Cookie header",
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
@@ -140,10 +126,6 @@ export default function (pi: ExtensionAPI) {
}
if (error instanceof SearchError) {
if (error.code === "AUTH") {
// Clear cached token on auth rejection so next call triggers re-login.
await clearToken().catch(() => undefined);
}
return {
content: [{ type: "text", text: `Perplexity search failed: ${error.message}` }],
details: { sourceCount, queryMs, isError: true },
+41
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, mock, test } from "./test-helpers.js";
import { SearchError } from "../src/search/types.js";
afterEach(() => {
mock.restore();
});
@@ -66,4 +68,43 @@ describe("perplexity_search execute", () => {
expect(result.details.incognito).toBe(false);
expect(result.details.model).toBe("gpt54");
});
test("does not clear cached credentials on Perplexity auth rejection", async () => {
const authenticate = mock(async () => "jwt-token");
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
const clearToken = mock(async () => undefined);
const loadConfig = mock(async () => ({}));
const resolveSearchDefaults = mock(() => ({ model: "pplx_pro_upgraded", incognito: true }));
const searchPerplexity = mock(async () => {
throw new SearchError("AUTH", "Perplexity rejected authentication (401/403).");
});
mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
mock.module("../src/auth/storage.js", () => ({ clearToken }));
mock.module("../src/config.js", () => ({
getConfigPath: () => "/tmp/pi-perplexity-config.json",
loadConfig,
resolveSearchDefaults,
saveConfig: mock(async () => undefined),
}));
mock.module("../src/search/client.js", () => ({ searchPerplexity }));
const { default: registerExtension } = await import(`../src/index.js?test=${crypto.randomUUID()}`);
let execute: ((toolCallId: string, params: any, signal?: AbortSignal, onUpdate?: any, ctx?: any) => Promise<any>) | undefined;
registerExtension({
registerCommand() {
return undefined;
},
registerTool(tool: { execute: typeof execute }) {
execute = tool.execute;
},
} as any);
const result = await execute!("tool-1", { query: "hello" }, undefined, undefined, { ui: {} });
expect(result.details.isError).toBe(true);
expect(String(result.content[0].text)).toContain("Perplexity search failed");
expect(clearToken).toHaveBeenCalledTimes(0);
});
});