From f8919e254cb34095168ba0ebec6f0c51d242cd8f Mon Sep 17 00:00:00 2001 From: Ivan Pereira <183991+ivanrvpereira@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:51:37 +0100 Subject: [PATCH] refactor(auth): separate browser credential parsing --- src/auth/browser.ts | 202 +++++++++++++++++++++++++++++++++++++ src/auth/login.ts | 183 ++------------------------------- src/commands/login.ts | 16 +-- src/index.ts | 22 +--- test/index-execute.test.ts | 41 ++++++++ 5 files changed, 254 insertions(+), 210 deletions(-) create mode 100644 src/auth/browser.ts diff --git a/src/auth/browser.ts b/src/auth/browser.ts new file mode 100644 index 0000000..4f95902 --- /dev/null +++ b/src/auth/browser.ts @@ -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 { + const cookies = new Map(); + 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, 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"); +} diff --git a/src/auth/login.ts b/src/auth/login.ts index d89a876..879c112 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -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 { - const cookies = new Map(); - 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, 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]); diff --git a/src/commands/login.ts b/src/commands/login.ts index 9c4bdb5..577c13b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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", diff --git a/src/index.ts b/src/index.ts index 51b0c52..aeb5924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 }, diff --git a/test/index-execute.test.ts b/test/index-execute.test.ts index c4e9b21..f7eff8a 100644 --- a/test/index-execute.test.ts +++ b/test/index-execute.test.ts @@ -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) | 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); + }); });