feat(auth): add browser cookie login fallback
This commit is contained in:
+218
-2
@@ -6,6 +6,8 @@ const originalFetch = globalThis.fetch;
|
||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||
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 {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
||||
@@ -46,6 +48,18 @@ function restoreEnv(): void {
|
||||
} else {
|
||||
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(() => {
|
||||
@@ -158,7 +172,7 @@ describe("auth/login", () => {
|
||||
|
||||
const token = await authenticate();
|
||||
|
||||
expect(token).toBe(cachedToken);
|
||||
expect(token.access).toBe(cachedToken);
|
||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(0);
|
||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||
@@ -216,7 +230,7 @@ describe("auth/login", () => {
|
||||
promptForOtp: async () => "123456",
|
||||
});
|
||||
|
||||
expect(token).toBe(otpToken);
|
||||
expect(token.access).toBe(otpToken);
|
||||
expect(loadTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||
@@ -242,6 +256,208 @@ describe("auth/login", () => {
|
||||
|
||||
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 () => {
|
||||
process.env.PI_AUTH_NO_BORROW = "1";
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const originalFetch = globalThis.fetch;
|
||||
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
|
||||
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
|
||||
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) ---
|
||||
|
||||
@@ -45,6 +47,8 @@ function restoreEnv(): void {
|
||||
["PI_AUTH_NO_BORROW", originalBorrow],
|
||||
["PI_PERPLEXITY_EMAIL", originalEmail],
|
||||
["PI_PERPLEXITY_OTP", originalOtp],
|
||||
["PI_PERPLEXITY_TOKEN", originalToken],
|
||||
["PI_PERPLEXITY_COOKIE", originalCookie],
|
||||
] as const) {
|
||||
if (original === undefined) {
|
||||
delete process.env[key];
|
||||
@@ -131,7 +135,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
||||
promptForOtp: async () => TEST_OTP,
|
||||
});
|
||||
|
||||
expect(token).toBe(REAL_JWE_TOKEN);
|
||||
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -212,7 +216,7 @@ describe("OTP login flow (from real captured responses)", () => {
|
||||
|
||||
const token = await authenticate({ promptForEmail, promptForOtp });
|
||||
|
||||
expect(token).toBe(REAL_JWE_TOKEN);
|
||||
expect(token.access).toBe(REAL_JWE_TOKEN);
|
||||
expect(promptForEmail).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,
|
||||
limit: 1,
|
||||
},
|
||||
token.access,
|
||||
token,
|
||||
);
|
||||
|
||||
expect(result.answer.trim().startsWith("OK")).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ afterEach(() => {
|
||||
describe("perplexity_search execute", () => {
|
||||
test("includes effective config values in the search request and result details", async () => {
|
||||
const authenticate = mock(async () => "jwt-token");
|
||||
const saveBrowserAuthInput = mock(async () => ({ type: "oauth", access: "jwt-token" }));
|
||||
const loadConfig = mock(async () => ({ model: "gpt54", incognito: false }));
|
||||
const resolveSearchDefaults = mock(() => ({ model: "gpt54", incognito: false }));
|
||||
const searchPerplexity = mock(async () => ({
|
||||
@@ -16,7 +17,7 @@ describe("perplexity_search execute", () => {
|
||||
uuid: "req-123",
|
||||
}));
|
||||
|
||||
mock.module("../src/auth/login.js", () => ({ authenticate }));
|
||||
mock.module("../src/auth/login.js", () => ({ authenticate, saveBrowserAuthInput }));
|
||||
mock.module("../src/config.js", () => ({
|
||||
getConfigPath: () => "/tmp/pi-perplexity-config.json",
|
||||
loadConfig,
|
||||
|
||||
@@ -120,6 +120,26 @@ describe("searchPerplexity", () => {
|
||||
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 () => {
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user