feat(auth): add browser cookie login fallback
This commit is contained in:
@@ -5,7 +5,7 @@ A [pi](https://github.com/badlogic/pi-mono) extension that gives your coding age
|
|||||||
|
|
||||||
- [pi](https://github.com/badlogic/pi-mono) coding agent with its bundled Node runtime (Node 18.14.1+ if running outside pi)
|
- [pi](https://github.com/badlogic/pi-mono) coding agent with its bundled Node runtime (Node 18.14.1+ if running outside pi)
|
||||||
- A **Perplexity Pro** or **Max** subscription
|
- A **Perplexity Pro** or **Max** subscription
|
||||||
- macOS (for zero-interaction auth) _or_ an interactive terminal (for email OTP)
|
- macOS (for zero-interaction auth), an interactive terminal (for email OTP), or a signed-in browser session cookie/token
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -21,16 +21,34 @@ pi install github:ivanrvpereira/pi-perplexity
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Run the login command once to cache your token:
|
Run login once:
|
||||||
|
|
||||||
```
|
```
|
||||||
/perplexity-login
|
/perplexity-login
|
||||||
```
|
```
|
||||||
|
|
||||||
The extension tries two methods in order:
|
This usually reuses an existing cached login, borrows the Perplexity macOS app login if available, or asks for your email OTP code.
|
||||||
|
|
||||||
1. **macOS Desktop App** _(zero interaction)_ — borrows the JWT directly from the Perplexity macOS app if it's installed and signed in. Nothing to type.
|
If login fails with a Cloudflare “Just a moment...” page, use browser login instead:
|
||||||
2. **Email OTP** _(interactive fallback)_ — prompts for your Perplexity email, sends a one-time code, and prompts for the code.
|
|
||||||
|
```
|
||||||
|
/perplexity-login --browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser login
|
||||||
|
|
||||||
|
Use this on Linux/headless machines when direct OTP is blocked.
|
||||||
|
|
||||||
|
1. Open `https://www.perplexity.ai` and sign in.
|
||||||
|
2. Open browser DevTools → **Network**.
|
||||||
|
3. Reload the page, or ask one Perplexity question.
|
||||||
|
4. Right-click a `www.perplexity.ai` request, preferably `perplexity_ask`.
|
||||||
|
5. Choose **Copy** → **Copy as cURL**.
|
||||||
|
6. Paste the copied cURL command into the pi prompt.
|
||||||
|
|
||||||
|
The copied text must include cookies. A good copy contains one of `-b`, `--cookie`, or `Cookie:`, and should include `__Secure-next-auth.session-token`. If it does not, copy a different request.
|
||||||
|
|
||||||
|
You can also paste just the request `Cookie:` header, or just the `__Secure-next-auth.session-token` cookie value. Full cURL is recommended because it also includes Cloudflare cookies like `cf_clearance`.
|
||||||
|
|
||||||
The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reused across sessions. On auth failure, run `/perplexity-login --force` to clear and re-authenticate.
|
The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reused across sessions. On auth failure, run `/perplexity-login --force` to clear and re-authenticate.
|
||||||
|
|
||||||
@@ -39,6 +57,8 @@ The token is saved to `~/.config/pi-perplexity/auth.json` (mode `0600`) and reus
|
|||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PI_AUTH_NO_BORROW=1` | Skip macOS desktop app extraction and go straight to email OTP |
|
| `PI_AUTH_NO_BORROW=1` | Skip macOS desktop app extraction and go straight to email OTP |
|
||||||
|
| `PI_PERPLEXITY_TOKEN` | Raw Perplexity session token/JWT/JWE copied from a browser or another machine |
|
||||||
|
| `PI_PERPLEXITY_COOKIE` / `PI_PERPLEXITY_COOKIES` | Full Perplexity `Cookie` header copied from a signed-in browser |
|
||||||
| `PI_PERPLEXITY_EMAIL` | Pre-fill the email prompt (useful for non-interactive setups) |
|
| `PI_PERPLEXITY_EMAIL` | Pre-fill the email prompt (useful for non-interactive setups) |
|
||||||
| `PI_PERPLEXITY_OTP` | Pre-fill the OTP prompt |
|
| `PI_PERPLEXITY_OTP` | Pre-fill the OTP prompt |
|
||||||
|
|
||||||
@@ -86,7 +106,7 @@ Queries default to `is_incognito: true`, but you can override that per call or v
|
|||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
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. Network calls use the Node runtime already provided by pi; no extra runtime is required. Email OTP auth requires `Headers.getSetCookie()` support so auth cookies are exposed reliably.
|
The extension calls Perplexity's internal SSE endpoint (`perplexity_ask`) using your subscription credentials obtained from the macOS app, email OTP, or a browser-imported session. Responses stream as incremental events that are merged into a final result. Network calls use the Node runtime already provided by pi; no extra runtime is required. Email OTP auth requires `Headers.getSetCookie()` support so auth cookies are exposed reliably.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
+300
-16
@@ -1,7 +1,7 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { AuthError } from "../search/types.js";
|
import { AuthError, type StoredToken } from "../search/types.js";
|
||||||
import { errorMessage } from "../render/util.js";
|
import { errorMessage } from "../render/util.js";
|
||||||
import { loadToken, saveToken } from "./storage.js";
|
import { loadToken, saveToken } from "./storage.js";
|
||||||
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
||||||
@@ -14,14 +14,32 @@ const DESKTOP_AUTH_HELP =
|
|||||||
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
"Install the Perplexity desktop app and sign in, or set PI_AUTH_NO_BORROW=1 to skip desktop token borrowing.";
|
||||||
const OTP_AUTH_HELP =
|
const OTP_AUTH_HELP =
|
||||||
"Provide credentials via PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP, or run interactively to enter email and OTP.";
|
"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 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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
class BrowserChallengeError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "BrowserChallengeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticateOptions {
|
export interface AuthenticateOptions {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
promptForEmail?: () => Promise<string | null | undefined>;
|
promptForEmail?: () => Promise<string | null | undefined>;
|
||||||
promptForOtp?: (email: string) => Promise<string | null | undefined>;
|
promptForOtp?: (email: string) => Promise<string | null | undefined>;
|
||||||
|
promptForBrowserAuth?: () => Promise<string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInput(value: string | null | undefined): string | null {
|
function normalizeInput(value: string | null | undefined): string | null {
|
||||||
@@ -29,6 +47,227 @@ function normalizeInput(value: string | null | undefined): string | null {
|
|||||||
return trimmed ? trimmed : 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\\]+)/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]);
|
||||||
|
if (value) {
|
||||||
|
return credentialsFromTokenValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of COOKIE_ENV_KEYS) {
|
||||||
|
const value = normalizeInput(process.env[key]);
|
||||||
|
if (value) {
|
||||||
|
const credentials = parseBrowserAuthInput(value);
|
||||||
|
if (!credentials?.cookies) {
|
||||||
|
throw new AuthError(
|
||||||
|
"NO_TOKEN",
|
||||||
|
`${key} is set but does not contain a signed-in Perplexity browser cookie. ${browserAuthFailureMessage(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBrowserAuthInput(input: string): Promise<StoredToken> {
|
||||||
|
const credentials = parseBrowserAuthInput(input);
|
||||||
|
if (!credentials) {
|
||||||
|
throw new AuthError("NO_TOKEN", browserAuthFailureMessage(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveToken(credentials);
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForBrowserCredentials(
|
||||||
|
options: AuthenticateOptions,
|
||||||
|
): Promise<StoredToken | null> {
|
||||||
|
const input = normalizeInput(await options.promptForBrowserAuth?.());
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return saveBrowserAuthInput(input);
|
||||||
|
}
|
||||||
|
|
||||||
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
|
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -55,6 +294,28 @@ function formatHttpFailure(action: string, response: AuthFetchResponse): string
|
|||||||
return `${action} (HTTP ${response.status}${suffix}).`;
|
return `${action} (HTTP ${response.status}${suffix}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBrowserChallengeResponse(response: AuthFetchResponse): boolean {
|
||||||
|
const body = response.bodyText.toLowerCase();
|
||||||
|
return (
|
||||||
|
body.includes("just a moment") ||
|
||||||
|
body.includes("enable javascript and cookies") ||
|
||||||
|
body.includes("_cf_chl_opt") ||
|
||||||
|
body.includes("cdn-cgi/challenge-platform") ||
|
||||||
|
body.includes("cf-browser-verification")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwHttpFailure(action: string, response: AuthFetchResponse): never {
|
||||||
|
const failure = formatHttpFailure(action, response);
|
||||||
|
if (isBrowserChallengeResponse(response)) {
|
||||||
|
throw new BrowserChallengeError(
|
||||||
|
`${failure} Perplexity returned a browser challenge that Node fetch cannot solve.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(failure);
|
||||||
|
}
|
||||||
|
|
||||||
function cookieHeaderFrom(cookies: string[]): string {
|
function cookieHeaderFrom(cookies: string[]): string {
|
||||||
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
|
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
@@ -84,7 +345,7 @@ async function loginWithEmailOtp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!csrfResponse.ok) {
|
if (!csrfResponse.ok) {
|
||||||
throw new Error(formatHttpFailure("Failed to fetch CSRF token", csrfResponse));
|
throwHttpFailure("Failed to fetch CSRF token", csrfResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrfPayload = parseJsonResponse("CSRF token response", csrfResponse);
|
const csrfPayload = parseJsonResponse("CSRF token response", csrfResponse);
|
||||||
@@ -115,7 +376,7 @@ async function loginWithEmailOtp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!emailResponse.ok) {
|
if (!emailResponse.ok) {
|
||||||
throw new Error(formatHttpFailure("Failed to send OTP email", emailResponse));
|
throwHttpFailure("Failed to send OTP email", emailResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otp =
|
const otp =
|
||||||
@@ -140,11 +401,13 @@ async function loginWithEmailOtp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!otpResponse.ok) {
|
if (!otpResponse.ok) {
|
||||||
throw new Error(formatHttpFailure("OTP verification failed", otpResponse));
|
throwHttpFailure("OTP verification failed", otpResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otpPayload = parseJsonResponse("OTP verification response", otpResponse);
|
const otpPayload = parseJsonResponse("OTP verification response", otpResponse);
|
||||||
const token = extractTokenFromPayload(otpPayload);
|
const token =
|
||||||
|
extractTokenFromPayload(otpPayload) ??
|
||||||
|
extractSessionTokenFromCookieHeader(cookieHeaderFrom(otpResponse.cookies));
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error("Perplexity OTP response did not include a token.");
|
throw new Error("Perplexity OTP response did not include a token.");
|
||||||
}
|
}
|
||||||
@@ -171,31 +434,44 @@ export async function extractFromDesktopApp(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run auth strategy: load cached → try desktop extraction → save → throw AuthError if all fail. */
|
/** Run auth strategy: load cached → env token/cookies → desktop extraction → email OTP → browser paste fallback. */
|
||||||
export async function authenticate(options: AuthenticateOptions = {}): Promise<string> {
|
export async function authenticate(options: AuthenticateOptions = {}): Promise<StoredToken> {
|
||||||
const cached = await loadToken();
|
const cached = await loadToken();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached.access;
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envCredentials = credentialsFromEnvironment();
|
||||||
|
if (envCredentials) {
|
||||||
|
await saveToken(envCredentials);
|
||||||
|
return envCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
|
const borrowDisabled = process.env.PI_AUTH_NO_BORROW === "1";
|
||||||
if (!borrowDisabled) {
|
if (!borrowDisabled) {
|
||||||
const desktopToken = await extractFromDesktopApp();
|
const desktopToken = await extractFromDesktopApp();
|
||||||
if (desktopToken) {
|
if (desktopToken) {
|
||||||
await saveToken({
|
const credentials: StoredToken = {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: desktopToken,
|
access: desktopToken,
|
||||||
});
|
};
|
||||||
return desktopToken;
|
await saveToken(credentials);
|
||||||
|
return credentials;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const email =
|
const email =
|
||||||
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
normalizeInput(process.env.PI_PERPLEXITY_EMAIL) ??
|
||||||
normalizeInput(await options.promptForEmail?.());
|
normalizeInput(await options.promptForEmail?.());
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
const browserCredentials = await promptForBrowserCredentials(options);
|
||||||
|
if (browserCredentials) {
|
||||||
|
return browserCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"NO_TOKEN",
|
"NO_TOKEN",
|
||||||
`Could not find a desktop token and no email was provided for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP}`,
|
`Could not find a desktop token, browser token/cookie, or email for OTP fallback. ${DESKTOP_AUTH_HELP} ${OTP_AUTH_HELP} ${BROWSER_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,16 +484,24 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof BrowserChallengeError) {
|
||||||
|
const browserCredentials = await promptForBrowserCredentials(options);
|
||||||
|
if (browserCredentials) {
|
||||||
|
return browserCredentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXTRACTION_FAILED",
|
"EXTRACTION_FAILED",
|
||||||
`Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP}`,
|
`Email OTP authentication failed: ${errorMessage(error)}. ${OTP_AUTH_HELP} ${BROWSER_AUTH_HELP}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveToken({
|
const credentials: StoredToken = {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: otpToken,
|
access: otpToken,
|
||||||
email,
|
email,
|
||||||
});
|
};
|
||||||
return otpToken;
|
await saveToken(credentials);
|
||||||
|
return credentials;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -12,10 +12,11 @@ function isStoredToken(value: unknown): value is StoredToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
|
const hasAccess = typeof candidate.access === "string" && candidate.access.length > 0;
|
||||||
|
const hasCookies = typeof candidate.cookies === "string" && candidate.cookies.length > 0;
|
||||||
return (
|
return (
|
||||||
candidate.type === "oauth" &&
|
candidate.type === "oauth" &&
|
||||||
typeof candidate.access === "string" &&
|
(hasAccess || hasCookies)
|
||||||
candidate.access.length > 0
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-5
@@ -1,6 +1,6 @@
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { authenticate } from "../auth/login.js";
|
import { authenticate, saveBrowserAuthInput } from "../auth/login.js";
|
||||||
import { clearToken } from "../auth/storage.js";
|
import { clearToken } from "../auth/storage.js";
|
||||||
import { AuthError } from "../search/types.js";
|
import { AuthError } from "../search/types.js";
|
||||||
import { errorMessage } from "../render/util.js";
|
import { errorMessage } from "../render/util.js";
|
||||||
@@ -9,6 +9,7 @@ const LOGIN_COMMAND_NAME = "perplexity-login";
|
|||||||
|
|
||||||
interface ParsedCommandArgs {
|
interface ParsedCommandArgs {
|
||||||
forceRefresh: boolean;
|
forceRefresh: boolean;
|
||||||
|
browserAuth: boolean;
|
||||||
showHelp: boolean;
|
showHelp: boolean;
|
||||||
unknown: string[];
|
unknown: string[];
|
||||||
}
|
}
|
||||||
@@ -20,6 +21,7 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
|
|||||||
.filter((token) => token.length > 0);
|
.filter((token) => token.length > 0);
|
||||||
|
|
||||||
let forceRefresh = false;
|
let forceRefresh = false;
|
||||||
|
let browserAuth = false;
|
||||||
let showHelp = false;
|
let showHelp = false;
|
||||||
const unknown: string[] = [];
|
const unknown: string[] = [];
|
||||||
|
|
||||||
@@ -34,14 +36,34 @@ function parseCommandArgs(args: string): ParsedCommandArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token === "--browser" || token === "--cookie" || token === "--manual") {
|
||||||
|
browserAuth = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
unknown.push(token);
|
unknown.push(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { forceRefresh, showHelp, unknown };
|
return { forceRefresh, browserAuth, showHelp, unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageText(): string {
|
function usageText(): string {
|
||||||
return `Usage: /${LOGIN_COMMAND_NAME} [--force]\n\nFlags:\n --force, --refresh, -f Clear cached token before login\n --help, -h Show this help`;
|
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 {
|
export function registerPerplexityCommands(pi: ExtensionAPI): void {
|
||||||
@@ -77,13 +99,39 @@ export function registerPerplexityCommands(pi: ExtensionAPI): void {
|
|||||||
return value?.trim() || undefined;
|
return value?.trim() || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const promptForBrowserAuth = async (): Promise<string | undefined> => {
|
||||||
|
ctx.ui.notify(browserLoginInstructions(), "info");
|
||||||
|
const value = await ctx.ui.input(
|
||||||
|
"Paste copied cURL command or Cookie header",
|
||||||
|
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
|
||||||
|
);
|
||||||
|
return value?.trim() || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authenticate({ promptForEmail, promptForOtp });
|
if (parsed.browserAuth) {
|
||||||
|
const input = await promptForBrowserAuth();
|
||||||
|
if (!input) {
|
||||||
|
ctx.ui.notify("Perplexity browser login canceled.", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBrowserAuthInput(input);
|
||||||
|
ctx.ui.notify("Perplexity browser auth saved.", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await authenticate({ promptForEmail, promptForOtp, promptForBrowserAuth });
|
||||||
ctx.ui.notify("Perplexity login successful. Token saved.", "info");
|
ctx.ui.notify("Perplexity login successful. Token saved.", "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError && error.code === "NO_TOKEN") {
|
if (error instanceof AuthError && error.code === "NO_TOKEN") {
|
||||||
|
if (parsed.browserAuth) {
|
||||||
|
ctx.ui.notify(error.message, "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Perplexity login canceled. Re-run /perplexity-login and provide email + OTP, or set PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP.",
|
"Perplexity login canceled. Re-run /perplexity-login for email + OTP, use /perplexity-login --browser, or set PI_PERPLEXITY_EMAIL/PI_PERPLEXITY_OTP/PI_PERPLEXITY_COOKIE.",
|
||||||
"warning",
|
"warning",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
+23
-2
@@ -54,10 +54,31 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return ctx.ui.input(label, placeholder);
|
return ctx.ui.input(label, placeholder);
|
||||||
};
|
};
|
||||||
|
|
||||||
const jwt = await authenticate({
|
const auth = await authenticate({
|
||||||
...(signal !== undefined ? { signal } : {}),
|
...(signal !== undefined ? { signal } : {}),
|
||||||
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
promptForEmail: async () => promptInput("Perplexity email", "you@example.com"),
|
||||||
promptForOtp: async (email) => promptInput(`Enter OTP sent to ${email}`, "123456"),
|
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",
|
||||||
|
);
|
||||||
|
return promptInput(
|
||||||
|
"Paste copied cURL command or Cookie header",
|
||||||
|
"curl 'https://www.perplexity.ai/' -H 'Cookie: ...'",
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -88,7 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
...(params.recency !== undefined ? { recency: params.recency } : {}),
|
...(params.recency !== undefined ? { recency: params.recency } : {}),
|
||||||
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
||||||
},
|
},
|
||||||
jwt,
|
auth,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+18
-7
@@ -1,5 +1,5 @@
|
|||||||
import { mergeEvent, readSseEvents } from "./stream.js";
|
import { mergeEvent, readSseEvents } from "./stream.js";
|
||||||
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
|
import type { SearchResult, StoredToken, StreamEvent, WebResult } from "./types.js";
|
||||||
import { SearchError } from "./types.js";
|
import { SearchError } from "./types.js";
|
||||||
import { errorMessage } from "../render/util.js";
|
import { errorMessage } from "../render/util.js";
|
||||||
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
import { PERPLEXITY_USER_AGENT, PERPLEXITY_API_VERSION } from "../constants.js";
|
||||||
@@ -132,9 +132,12 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRequestHeaders(jwt: string, requestId: string): Record<string, string> {
|
type AuthCredentials = string | StoredToken;
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${jwt}`,
|
function buildRequestHeaders(auth: AuthCredentials, requestId: string): Record<string, string> {
|
||||||
|
const access = typeof auth === "string" ? auth : auth.access;
|
||||||
|
const cookies = typeof auth === "string" ? undefined : auth.cookies;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
Origin: "https://www.perplexity.ai",
|
Origin: "https://www.perplexity.ai",
|
||||||
@@ -145,13 +148,21 @@ function buildRequestHeaders(jwt: string, requestId: string): Record<string, str
|
|||||||
"X-Perplexity-Request-Reason": "submit",
|
"X-Perplexity-Request-Reason": "submit",
|
||||||
"X-Request-ID": requestId,
|
"X-Request-ID": requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (cookies) {
|
||||||
|
headers.Cookie = cookies;
|
||||||
|
} else if (access) {
|
||||||
|
headers.Authorization = `Bearer ${access}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapHttpError(status: number): SearchError {
|
function mapHttpError(status: number): SearchError {
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
return new SearchError(
|
return new SearchError(
|
||||||
"AUTH",
|
"AUTH",
|
||||||
"Perplexity rejected authentication (401/403). Sign in to Perplexity desktop app and retry.",
|
"Perplexity rejected authentication (401/403). Re-run /perplexity-login --force, or use /perplexity-login --browser if direct OTP is blocked.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,12 +182,12 @@ function mapHttpError(status: number): SearchError {
|
|||||||
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
|
/** Execute a Perplexity search: POST SSE, stream/merge events, extract answer + sources. Throws SearchError on failure. */
|
||||||
export async function searchPerplexity(
|
export async function searchPerplexity(
|
||||||
params: SearchParams,
|
params: SearchParams,
|
||||||
jwt: string,
|
auth: AuthCredentials,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const requestBody = buildRequestBody(params);
|
const requestBody = buildRequestBody(params);
|
||||||
const requestHeaders = buildRequestHeaders(jwt, requestId);
|
const requestHeaders = buildRequestHeaders(auth, requestId);
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-1
@@ -42,7 +42,8 @@ export interface StreamSource {
|
|||||||
|
|
||||||
export interface StoredToken {
|
export interface StoredToken {
|
||||||
type: "oauth";
|
type: "oauth";
|
||||||
access: string;
|
access?: string;
|
||||||
|
cookies?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+218
-2
@@ -6,6 +6,8 @@ const originalFetch = globalThis.fetch;
|
|||||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||||
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
||||||
|
const originalToken = process.env.PI_PERPLEXITY_TOKEN;
|
||||||
|
const originalCookie = process.env.PI_PERPLEXITY_COOKIE;
|
||||||
|
|
||||||
function createJwt(expiryMs: number): string {
|
function createJwt(expiryMs: number): string {
|
||||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||||
@@ -46,6 +48,18 @@ function restoreEnv(): void {
|
|||||||
} else {
|
} else {
|
||||||
process.env.PI_PERPLEXITY_OTP = originalOtp;
|
process.env.PI_PERPLEXITY_OTP = originalOtp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (originalToken === undefined) {
|
||||||
|
delete process.env.PI_PERPLEXITY_TOKEN;
|
||||||
|
} else {
|
||||||
|
process.env.PI_PERPLEXITY_TOKEN = originalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalCookie === undefined) {
|
||||||
|
delete process.env.PI_PERPLEXITY_COOKIE;
|
||||||
|
} else {
|
||||||
|
process.env.PI_PERPLEXITY_COOKIE = originalCookie;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -158,7 +172,7 @@ describe("auth/login", () => {
|
|||||||
|
|
||||||
const token = await authenticate();
|
const token = await authenticate();
|
||||||
|
|
||||||
expect(token).toBe(cachedToken);
|
expect(token.access).toBe(cachedToken);
|
||||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||||
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
@@ -216,7 +230,7 @@ describe("auth/login", () => {
|
|||||||
promptForOtp: async () => "123456",
|
promptForOtp: async () => "123456",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(token).toBe(otpToken);
|
expect(token.access).toBe(otpToken);
|
||||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -242,6 +256,208 @@ describe("auth/login", () => {
|
|||||||
|
|
||||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("authenticate saves PI_PERPLEXITY_TOKEN without desktop or OTP calls", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
process.env.PI_PERPLEXITY_TOKEN = "env-token";
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate();
|
||||||
|
|
||||||
|
expect(token.access).toBe("env-token");
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveTokenMock.mock.calls[0]?.[0]).toEqual({ type: "oauth", access: "env-token" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate saves browser Cookie header from PI_PERPLEXITY_COOKIE", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
process.env.PI_PERPLEXITY_COOKIE =
|
||||||
|
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`;
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate();
|
||||||
|
|
||||||
|
expect(token.cookies).toContain("__Secure-next-auth.session-token=");
|
||||||
|
expect(token.access).toBe(browserToken);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate rejects PI_PERPLEXITY_COOKIE without a signed-in session cookie", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
process.env.PI_PERPLEXITY_COOKIE = "pplx.visitor-id=visitor; cf_clearance=clearance";
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("NO_TOKEN");
|
||||||
|
expect((thrown as Error).message).toContain("PI_PERPLEXITY_COOKIE is set");
|
||||||
|
expect((thrown as Error).message).toContain("not a Perplexity signed-in session cookie");
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBrowserAuthInput extracts cookies from Copy as cURL", async () => {
|
||||||
|
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const curl = `curl 'https://www.perplexity.ai/rest/sse/perplexity_ask' \\
|
||||||
|
-H 'accept: text/event-stream' \\
|
||||||
|
-H 'cookie: pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance' \\
|
||||||
|
--data-raw '{"query":"hello"}'`;
|
||||||
|
|
||||||
|
const { parseBrowserAuthInput } = await importLoginModule();
|
||||||
|
const parsed = parseBrowserAuthInput(curl);
|
||||||
|
|
||||||
|
expect(parsed?.cookies).toBe(
|
||||||
|
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`,
|
||||||
|
);
|
||||||
|
expect(parsed?.access).toBe(browserToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseBrowserAuthInput extracts cookies from --cookie= cURL form", async () => {
|
||||||
|
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
const curl = `curl 'https://www.perplexity.ai/rest/sse/perplexity_ask' \\
|
||||||
|
--cookie='pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance' \\
|
||||||
|
--data-raw '{"query":"hello"}'`;
|
||||||
|
|
||||||
|
const { parseBrowserAuthInput } = await importLoginModule();
|
||||||
|
const parsed = parseBrowserAuthInput(curl);
|
||||||
|
|
||||||
|
expect(parsed?.cookies).toBe(
|
||||||
|
`pplx.visitor-id=visitor; __Secure-next-auth.session-token=${browserToken}; cf_clearance=clearance`,
|
||||||
|
);
|
||||||
|
expect(parsed?.access).toBe(browserToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saveBrowserAuthInput explains Copy as cURL without cookies", async () => {
|
||||||
|
const curl = `curl 'https://www.perplexity.ai/' \\
|
||||||
|
-H 'Upgrade-Insecure-Requests: 1' \\
|
||||||
|
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36' \\
|
||||||
|
-H 'sec-ch-ua: "Chromium";v="149", "Not)A;Brand";v="24"' \\
|
||||||
|
-H 'sec-ch-ua-mobile: ?0' \\
|
||||||
|
-H 'sec-ch-ua-platform: "macOS"'`;
|
||||||
|
|
||||||
|
const { saveBrowserAuthInput } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await saveBrowserAuthInput(curl);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("NO_TOKEN");
|
||||||
|
expect((thrown as Error).message).toContain("The cURL command you pasted does not include cookies");
|
||||||
|
expect((thrown as Error).message).toContain("-b");
|
||||||
|
expect((thrown as Error).message).toContain("__Secure-next-auth.session-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate reproduces Cloudflare CSRF failure without browser fallback", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = mock(async () =>
|
||||||
|
new Response("<!DOCTYPE html><html><head><title>Just a moment...</title></head></html>", {
|
||||||
|
status: 403,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail: async () => "user@example.com",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(AuthError);
|
||||||
|
expect((thrown as AuthError).code).toBe("EXTRACTION_FAILED");
|
||||||
|
expect((thrown as Error).message).toContain("Failed to fetch CSRF token");
|
||||||
|
expect((thrown as Error).message).toContain("browser challenge");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate falls back to browser auth when OTP CSRF hits Cloudflare", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
const browserToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const loadTokenMock = mock(async () => null);
|
||||||
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
|
const clearTokenMock = mock(async () => undefined);
|
||||||
|
|
||||||
|
mock.module("../../src/auth/storage.js", () => ({
|
||||||
|
loadToken: loadTokenMock,
|
||||||
|
saveToken: saveTokenMock,
|
||||||
|
clearToken: clearTokenMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = mock(async () =>
|
||||||
|
new Response("<!DOCTYPE html><title>Just a moment...</title>", { status: 403 }),
|
||||||
|
);
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate({
|
||||||
|
promptForEmail: async () => "user@example.com",
|
||||||
|
promptForBrowserAuth: async () => `__Secure-next-auth.session-token=${browserToken}; cf_clearance=ok`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token.cookies).toContain("cf_clearance=ok");
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
|
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
|
||||||
process.env.PI_AUTH_NO_BORROW = "1";
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const originalFetch = globalThis.fetch;
|
|||||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||||
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
const originalOtp = process.env.PI_PERPLEXITY_OTP;
|
||||||
|
const originalToken = process.env.PI_PERPLEXITY_TOKEN;
|
||||||
|
const originalCookie = process.env.PI_PERPLEXITY_COOKIE;
|
||||||
|
|
||||||
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
|
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ function restoreEnv(): void {
|
|||||||
["PI_AUTH_NO_BORROW", originalBorrow],
|
["PI_AUTH_NO_BORROW", originalBorrow],
|
||||||
["PI_PERPLEXITY_EMAIL", originalEmail],
|
["PI_PERPLEXITY_EMAIL", originalEmail],
|
||||||
["PI_PERPLEXITY_OTP", originalOtp],
|
["PI_PERPLEXITY_OTP", originalOtp],
|
||||||
|
["PI_PERPLEXITY_TOKEN", originalToken],
|
||||||
|
["PI_PERPLEXITY_COOKIE", originalCookie],
|
||||||
] as const) {
|
] as const) {
|
||||||
if (original === undefined) {
|
if (original === undefined) {
|
||||||
delete process.env[key];
|
delete process.env[key];
|
||||||
@@ -131,7 +135,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
|||||||
promptForOtp: async () => TEST_OTP,
|
promptForOtp: async () => TEST_OTP,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(token).toBe(REAL_JWE_TOKEN);
|
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
@@ -212,7 +216,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
|||||||
|
|
||||||
const token = await authenticate({ promptForEmail, promptForOtp });
|
const token = await authenticate({ promptForEmail, promptForOtp });
|
||||||
|
|
||||||
expect(token).toBe(REAL_JWE_TOKEN);
|
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||||
expect(promptForEmail).toHaveBeenCalledTimes(0);
|
expect(promptForEmail).toHaveBeenCalledTimes(0);
|
||||||
expect(promptForOtp).toHaveBeenCalledTimes(0);
|
expect(promptForOtp).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, describe, expect, mock, test } from "../test-helpers.js";
|
||||||
|
|
||||||
|
import { AuthError } from "../../src/search/types.js";
|
||||||
|
|
||||||
|
async function importCommandModule() {
|
||||||
|
return import(`../../src/commands/login.js?test=${crypto.randomUUID()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perplexity-login command", () => {
|
||||||
|
test("shows browser auth parse errors instead of generic cancellation", async () => {
|
||||||
|
const expectedMessage = "The cURL command you pasted does not include cookies";
|
||||||
|
const authenticate = mock(async () => ({ type: "oauth", access: "token" }));
|
||||||
|
const saveBrowserAuthInput = mock(async () => {
|
||||||
|
throw new AuthError("NO_TOKEN", expectedMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
|
||||||
|
|
||||||
|
const registered = {
|
||||||
|
handler: undefined as undefined | ((args: string, ctx: unknown) => Promise<void>),
|
||||||
|
};
|
||||||
|
const registerCommand = mock((name: string, command: { handler: (args: string, ctx: unknown) => Promise<void> }) => {
|
||||||
|
expect(name).toBe("perplexity-login");
|
||||||
|
registered.handler = command.handler;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerPerplexityCommands } = await importCommandModule();
|
||||||
|
registerPerplexityCommands({ registerCommand } as never);
|
||||||
|
|
||||||
|
const input = mock(async () => "curl 'https://www.perplexity.ai/' -H 'User-Agent: browser'");
|
||||||
|
const notify = mock((_message: string, _level: string) => undefined);
|
||||||
|
|
||||||
|
await registered.handler?.("--browser", { ui: { input, notify } });
|
||||||
|
|
||||||
|
const lastNotification = notify.mock.calls.at(-1);
|
||||||
|
expect(lastNotification?.[0]).toBe(expectedMessage);
|
||||||
|
expect(lastNotification?.[1]).toBe("warning");
|
||||||
|
expect(saveBrowserAuthInput).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,7 +53,7 @@ describe("Perplexity model selection e2e", () => {
|
|||||||
incognito: true,
|
incognito: true,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
},
|
},
|
||||||
token.access,
|
token,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.answer.trim().startsWith("OK")).toBe(true);
|
expect(result.answer.trim().startsWith("OK")).toBe(true);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ afterEach(() => {
|
|||||||
describe("perplexity_search execute", () => {
|
describe("perplexity_search execute", () => {
|
||||||
test("includes effective config values in the search request and result details", async () => {
|
test("includes effective config values in the search request and result details", async () => {
|
||||||
const authenticate = mock(async () => "jwt-token");
|
const authenticate = mock(async () => "jwt-token");
|
||||||
|
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
|
||||||
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
|
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
|
||||||
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
|
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
|
||||||
const searchPerplexity = mock(async () => ({
|
const searchPerplexity = mock(async () => ({
|
||||||
@@ -16,7 +17,7 @@ describe("perplexity_search execute", () => {
|
|||||||
uuid: "req-123",
|
uuid: "req-123",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("../src/auth/login.js", () => ({ authenticate }));
|
mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
|
||||||
mock.module("../src/config.js", () => ({
|
mock.module("../src/config.js", () => ({
|
||||||
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
||||||
loadConfig,
|
loadConfig,
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ describe("searchPerplexity", () => {
|
|||||||
expect(body.params.is_incognito).toBe(false);
|
expect(body.params.is_incognito).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses Cookie header for browser-cookie credentials", async () => {
|
||||||
|
let capturedInit: RequestInit | undefined;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
capturedInit = init;
|
||||||
|
return createSseResponse([
|
||||||
|
{ status: "COMPLETED", final: true, text: "answer", blocks: [] },
|
||||||
|
]);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await searchPerplexity(
|
||||||
|
{ query: "q", model: "pplx_pro_upgraded", incognito: true },
|
||||||
|
{ type: "oauth", cookies: "__Secure-next-auth.session-token=session; cf_clearance=clearance" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers(capturedInit?.headers);
|
||||||
|
expect(headers.get("Cookie")).toBe("__Secure-next-auth.session-token=session; cf_clearance=clearance");
|
||||||
|
expect(headers.get("Authorization")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("passes incognito true through to request body", async () => {
|
test("passes incognito true through to request body", async () => {
|
||||||
let capturedInit: RequestInit | undefined;
|
let capturedInit: RequestInit | undefined;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user