Implement perplexity login/render modules and harden OAuth search flow
This commit is contained in:
+210
-20
@@ -10,8 +10,15 @@ const DESKTOP_AUTH_HELP =
|
|||||||
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 AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
|
const AUTH_BASE_URL = "https://www.perplexity.ai/api/auth";
|
||||||
|
const AUTH_SESSION_URL = `${AUTH_BASE_URL}/session`;
|
||||||
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
||||||
const PERPLEXITY_API_VERSION = "2.18";
|
const PERPLEXITY_API_VERSION = "2.18";
|
||||||
|
const SESSION_COOKIE_NAMES = [
|
||||||
|
"__Secure-next-auth.session-token",
|
||||||
|
"next-auth.session-token",
|
||||||
|
"__Secure-authjs.session-token",
|
||||||
|
"authjs.session-token",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -26,26 +33,162 @@ function normalizeInput(value: string | null | undefined): string | null {
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAuthHeaders(includeJsonContentType = false): Record<string, string> {
|
function decodeCookieValue(value: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyPerplexityToken(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = value.trim();
|
||||||
|
if (!token || token === "(null)") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token.includes(".") && token.length >= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthHeaders(
|
||||||
|
includeJsonContentType = false,
|
||||||
|
cookieHeader?: string,
|
||||||
|
): Record<string, string> {
|
||||||
return {
|
return {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
|
...(includeJsonContentType ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(cookieHeader ? { Cookie: cookieHeader } : {}),
|
||||||
"User-Agent": PERPLEXITY_USER_AGENT,
|
"User-Agent": PERPLEXITY_USER_AGENT,
|
||||||
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
|
"X-App-ApiVersion": PERPLEXITY_API_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSetCookieValues(headers: Headers): string[] {
|
||||||
|
const withSetCookie = headers as Headers & {
|
||||||
|
getSetCookie?: () => string[];
|
||||||
|
raw?: () => Record<string, string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof withSetCookie.getSetCookie === "function") {
|
||||||
|
return withSetCookie.getSetCookie();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof withSetCookie.raw === "function") {
|
||||||
|
const raw = withSetCookie.raw();
|
||||||
|
const setCookies = raw["set-cookie"];
|
||||||
|
if (Array.isArray(setCookies)) {
|
||||||
|
return setCookies;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const single = headers.get("set-cookie");
|
||||||
|
return single ? [single] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
class CookieJar {
|
||||||
|
#cookies = new Map<string, string>();
|
||||||
|
|
||||||
|
capture(headers: Headers): void {
|
||||||
|
for (const setCookie of getSetCookieValues(headers)) {
|
||||||
|
const firstPart = setCookie.split(";")[0]?.trim();
|
||||||
|
if (!firstPart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqIndex = firstPart.indexOf("=");
|
||||||
|
if (eqIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = firstPart.slice(0, eqIndex).trim();
|
||||||
|
const value = firstPart.slice(eqIndex + 1).trim();
|
||||||
|
if (!name || !value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#cookies.set(name, decodeCookieValue(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toHeader(): string | undefined {
|
||||||
|
if (this.#cookies.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...this.#cookies.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string): string | undefined {
|
||||||
|
return this.#cookies.get(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTokenFromCookies(headers: Headers, jar?: CookieJar): string | null {
|
||||||
|
for (const cookieName of SESSION_COOKIE_NAMES) {
|
||||||
|
const fromJar = jar?.get(cookieName);
|
||||||
|
if (isLikelyPerplexityToken(fromJar)) {
|
||||||
|
return fromJar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const setCookie of getSetCookieValues(headers)) {
|
||||||
|
for (const cookieName of SESSION_COOKIE_NAMES) {
|
||||||
|
const pattern = new RegExp(`(?:^|[;,]\\s*)${cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]+)`);
|
||||||
|
const match = setCookie.match(pattern);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeCookieValue(match[1] ?? "");
|
||||||
|
if (isLikelyPerplexityToken(decoded)) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractTokenFromPayload(payload: unknown): string | null {
|
function extractTokenFromPayload(payload: unknown): string | null {
|
||||||
if (!payload || typeof payload !== "object") {
|
if (!payload || typeof payload !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = payload as Record<string, unknown>;
|
const tokenKeyPattern = /(token|jwt|access)/i;
|
||||||
const possible = [candidate.token, candidate.accessToken, candidate.jwt];
|
const queue: unknown[] = [payload];
|
||||||
|
const seen = new Set<object>();
|
||||||
|
|
||||||
for (const token of possible) {
|
while (queue.length > 0) {
|
||||||
if (typeof token === "string" && token.split(".").length === 3) {
|
const current = queue.shift();
|
||||||
return token;
|
if (!current || typeof current !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(current)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(current);
|
||||||
|
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
for (const value of current) {
|
||||||
|
queue.push(value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
|
||||||
|
if (tokenKeyPattern.test(key) && isLikelyPerplexityToken(value)) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
queue.push(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +207,16 @@ async function loginWithEmailOtp(
|
|||||||
email: string,
|
email: string,
|
||||||
options: AuthenticateOptions,
|
options: AuthenticateOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const cookieJar = new CookieJar();
|
||||||
|
|
||||||
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
|
const csrfResponse = await fetch(`${AUTH_BASE_URL}/csrf`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(false, cookieJar.toHeader()),
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cookieJar.capture(csrfResponse.headers);
|
||||||
|
|
||||||
if (!csrfResponse.ok) {
|
if (!csrfResponse.ok) {
|
||||||
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
|
throw new Error(`Failed to fetch CSRF token (HTTP ${csrfResponse.status}).`);
|
||||||
}
|
}
|
||||||
@@ -84,11 +231,13 @@ async function loginWithEmailOtp(
|
|||||||
|
|
||||||
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
|
const emailResponse = await fetch(`${AUTH_BASE_URL}/signin-email`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(true),
|
headers: buildAuthHeaders(true, cookieJar.toHeader()),
|
||||||
body: JSON.stringify({ email, csrfToken }),
|
body: JSON.stringify({ email, csrfToken }),
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cookieJar.capture(emailResponse.headers);
|
||||||
|
|
||||||
if (!emailResponse.ok) {
|
if (!emailResponse.ok) {
|
||||||
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
|
throw new Error(`Failed to send OTP email (HTTP ${emailResponse.status}).`);
|
||||||
}
|
}
|
||||||
@@ -106,23 +255,58 @@ async function loginWithEmailOtp(
|
|||||||
|
|
||||||
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
|
const otpResponse = await fetch(`${AUTH_BASE_URL}/signin-otp`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(true),
|
headers: buildAuthHeaders(true, cookieJar.toHeader()),
|
||||||
body: JSON.stringify({ email, otp, csrfToken }),
|
body: JSON.stringify({ email, otp, csrfToken }),
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cookieJar.capture(otpResponse.headers);
|
||||||
|
|
||||||
if (!otpResponse.ok) {
|
if (!otpResponse.ok) {
|
||||||
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
|
throw new Error(`OTP verification failed (HTTP ${otpResponse.status}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const otpPayload = await readJsonResponse(otpResponse);
|
const otpPayload = await readJsonResponse(otpResponse);
|
||||||
const token = extractTokenFromPayload(otpPayload);
|
const directToken = extractTokenFromPayload(otpPayload);
|
||||||
|
if (directToken) {
|
||||||
if (!token) {
|
return directToken;
|
||||||
throw new Error("Perplexity OTP response did not include a JWT token.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
const cookieToken = extractTokenFromCookies(otpResponse.headers, cookieJar);
|
||||||
|
if (cookieToken) {
|
||||||
|
return cookieToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionResponse = await fetch(AUTH_SESSION_URL, {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildAuthHeaders(false, cookieJar.toHeader()),
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
cookieJar.capture(sessionResponse.headers);
|
||||||
|
|
||||||
|
if (sessionResponse.ok) {
|
||||||
|
const sessionPayload = await readJsonResponse(sessionResponse);
|
||||||
|
const sessionToken = extractTokenFromPayload(sessionPayload);
|
||||||
|
if (sessionToken) {
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookieToken = extractTokenFromCookies(sessionResponse.headers, cookieJar);
|
||||||
|
if (sessionCookieToken) {
|
||||||
|
return sessionCookieToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const otpBodyHint = otpPayload ? JSON.stringify(otpPayload).slice(0, 300) : "(empty or non-JSON)";
|
||||||
|
const otpKeys =
|
||||||
|
otpPayload && typeof otpPayload === "object"
|
||||||
|
? Object.keys(otpPayload as Record<string, unknown>).join(", ")
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Perplexity OTP response did not include an access token. OTP keys: ${otpKeys}. OTP body preview: ${otpBodyHint}. Session status: ${sessionResponse.status}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
/** Extract JWT from macOS Perplexity desktop app via `defaults read`. Returns null if app not installed or not logged in. */
|
||||||
@@ -133,8 +317,8 @@ export async function extractFromDesktopApp(): Promise<string | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("defaults", ["read", "ai.perplexity.mac", "authToken"]);
|
const { stdout } = await execFileAsync("defaults", ["read", "ai.perplexity.mac", "authToken"]);
|
||||||
const token = stdout.trim();
|
const token = normalizeInput(stdout);
|
||||||
if (!token || token.split(".").length !== 3) {
|
if (!token || token === "(null)") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +334,10 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
let sawExpiredToken = false;
|
let sawExpiredToken = false;
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
if (cached.expires > Date.now()) {
|
||||||
|
return cached.access;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isJwtExpired(cached.access)) {
|
if (!isJwtExpired(cached.access)) {
|
||||||
return cached.access;
|
return cached.access;
|
||||||
}
|
}
|
||||||
@@ -173,13 +361,14 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (desktopToken) {
|
if (desktopToken) {
|
||||||
if (isJwtExpired(desktopToken)) {
|
const desktopExpiry = decodeJwtExpiry(desktopToken);
|
||||||
|
if (desktopExpiry <= Date.now()) {
|
||||||
sawExpiredToken = true;
|
sawExpiredToken = true;
|
||||||
} else {
|
} else {
|
||||||
await saveToken({
|
await saveToken({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: desktopToken,
|
access: desktopToken,
|
||||||
expires: decodeJwtExpiry(desktopToken),
|
expires: desktopExpiry,
|
||||||
});
|
});
|
||||||
|
|
||||||
return desktopToken;
|
return desktopToken;
|
||||||
@@ -220,7 +409,8 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isJwtExpired(otpToken)) {
|
const otpExpiry = decodeJwtExpiry(otpToken);
|
||||||
|
if (otpExpiry <= Date.now()) {
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
|
`Perplexity returned an expired token from OTP login. Re-run login and verify OTP freshness. ${OTP_AUTH_HELP}`,
|
||||||
@@ -230,7 +420,7 @@ export async function authenticate(options: AuthenticateOptions = {}): Promise<s
|
|||||||
await saveToken({
|
await saveToken({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
access: otpToken,
|
access: otpToken,
|
||||||
expires: decodeJwtExpiry(otpToken),
|
expires: otpExpiry,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import { authenticate } from "../auth/login.js";
|
||||||
|
import { clearToken } from "../auth/storage.js";
|
||||||
|
import { AuthError } from "../search/types.js";
|
||||||
|
|
||||||
|
const LOGIN_COMMAND_NAME = "perplexity-login";
|
||||||
|
|
||||||
|
interface ParsedCommandArgs {
|
||||||
|
forceRefresh: boolean;
|
||||||
|
showHelp: boolean;
|
||||||
|
unknown: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommandArgs(args: string): ParsedCommandArgs {
|
||||||
|
const tokens = args
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length > 0);
|
||||||
|
|
||||||
|
let forceRefresh = false;
|
||||||
|
let showHelp = false;
|
||||||
|
const unknown: string[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token === "--force" || token === "--refresh" || token === "-f") {
|
||||||
|
forceRefresh = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token === "--help" || token === "-h") {
|
||||||
|
showHelp = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unknown.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { forceRefresh, showHelp, unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPerplexityCommands(pi: ExtensionAPI): void {
|
||||||
|
pi.registerCommand(LOGIN_COMMAND_NAME, {
|
||||||
|
description: "Authenticate Perplexity and persist token",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const parsed = parseCommandArgs(args);
|
||||||
|
|
||||||
|
if (parsed.showHelp) {
|
||||||
|
ctx.ui.notify(usageText(), "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.unknown.length > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`Unknown arguments: ${parsed.unknown.join(" ")}\n\n${usageText()}`,
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.forceRefresh) {
|
||||||
|
await clearToken().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
let canceledAtEmailPrompt = false;
|
||||||
|
let canceledAtOtpPrompt = false;
|
||||||
|
|
||||||
|
const promptForEmail = async (): Promise<string | undefined> => {
|
||||||
|
const value = await ctx.ui.input("Perplexity email", "you@example.com");
|
||||||
|
if (!value?.trim()) {
|
||||||
|
canceledAtEmailPrompt = true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptForOtp = async (email: string): Promise<string | undefined> => {
|
||||||
|
const value = await ctx.ui.input(`Enter OTP sent to ${email}`, "123456");
|
||||||
|
if (!value?.trim()) {
|
||||||
|
canceledAtOtpPrompt = true;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authenticate({
|
||||||
|
promptForEmail,
|
||||||
|
promptForOtp,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.ui.notify("Perplexity login successful. Token saved.", "info");
|
||||||
|
} catch (error) {
|
||||||
|
if (canceledAtEmailPrompt || canceledAtOtpPrompt) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
"Perplexity login canceled. Re-run /perplexity-login and provide email + OTP, or set PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP.",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
ctx.ui.notify(`Perplexity login failed: ${error.message}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`Perplexity login failed: ${(error as Error).message || "Unknown error"}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,13 +2,18 @@ import { StringEnum } from "@mariozechner/pi-ai";
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import { registerPerplexityCommands } from "./commands/login.js";
|
||||||
|
|
||||||
import { authenticate } from "./auth/login.js";
|
import { authenticate } from "./auth/login.js";
|
||||||
import { clearToken } from "./auth/storage.js";
|
import { clearToken } from "./auth/storage.js";
|
||||||
import { formatForLLM } from "./search/format.js";
|
import { formatForLLM } from "./search/format.js";
|
||||||
import { searchPerplexity } from "./search/client.js";
|
import { searchPerplexity } from "./search/client.js";
|
||||||
|
import { renderPerplexityCall } from "./render/call.js";
|
||||||
|
import { renderPerplexityResult } from "./render/result.js";
|
||||||
import { AuthError, SearchError } from "./search/types.js";
|
import { AuthError, SearchError } from "./search/types.js";
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
|
registerPerplexityCommands(pi);
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "perplexity_search",
|
name: "perplexity_search",
|
||||||
label: "Perplexity Search",
|
label: "Perplexity Search",
|
||||||
@@ -24,6 +29,8 @@ export default function (pi: ExtensionAPI) {
|
|||||||
Type.Number({ description: "Max sources to return", minimum: 1, maximum: 50 }),
|
Type.Number({ description: "Max sources to return", minimum: 1, maximum: 50 }),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
renderCall: renderPerplexityCall,
|
||||||
|
renderResult: renderPerplexityResult,
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let sourceCount = 0;
|
let sourceCount = 0;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
interface PerplexityCallArgs {
|
||||||
|
query?: unknown;
|
||||||
|
recency?: unknown;
|
||||||
|
limit?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENCY_VALUES = new Set(["hour", "day", "week", "month", "year"] as const);
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveNumber(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPerplexityCall(args: PerplexityCallArgs, theme: Theme): Text {
|
||||||
|
const query = asString(args?.query)?.trim();
|
||||||
|
const recencyRaw = asString(args?.recency)?.trim().toLowerCase();
|
||||||
|
const recency = recencyRaw && RECENCY_VALUES.has(recencyRaw as (typeof RECENCY_VALUES extends Set<infer T> ? T : never))
|
||||||
|
? recencyRaw
|
||||||
|
: undefined;
|
||||||
|
const limit = asPositiveNumber(args?.limit);
|
||||||
|
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("perplexity_search "));
|
||||||
|
text += query ? theme.fg("muted", truncate(query, 90)) : theme.fg("warning", "(missing query)");
|
||||||
|
|
||||||
|
if (recency) {
|
||||||
|
text += theme.fg("dim", ` • ${recency}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limit === "number") {
|
||||||
|
text += theme.fg("dim", ` • limit ${Math.round(limit)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
interface PerplexityResultDetails {
|
||||||
|
model?: unknown;
|
||||||
|
sourceCount?: unknown;
|
||||||
|
queryMs?: unknown;
|
||||||
|
uuid?: unknown;
|
||||||
|
toolCallId?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextContent(result: AgentToolResult<PerplexityResultDetails>): string | undefined {
|
||||||
|
if (!Array.isArray(result?.content)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of result.content) {
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"type" in item &&
|
||||||
|
item.type === "text" &&
|
||||||
|
"text" in item &&
|
||||||
|
typeof item.text === "string"
|
||||||
|
) {
|
||||||
|
const text = item.text.trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isErrorText(text: string | undefined): boolean {
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.startsWith("Authentication failed:") || text.startsWith("Perplexity search failed:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPerplexityResult(
|
||||||
|
result: AgentToolResult<PerplexityResultDetails>,
|
||||||
|
options: ToolRenderResultOptions,
|
||||||
|
theme: Theme,
|
||||||
|
): Text {
|
||||||
|
const details = (result?.details ?? {}) as PerplexityResultDetails;
|
||||||
|
const contentText = extractTextContent(result);
|
||||||
|
|
||||||
|
if (options?.isPartial) {
|
||||||
|
let partial = theme.fg("warning", "Perplexity: searching…");
|
||||||
|
|
||||||
|
if (contentText) {
|
||||||
|
partial += `\n${theme.fg("dim", truncate(contentText.replace(/\s+/g, " "), 140))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(partial, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = asString(details.error)?.trim();
|
||||||
|
if (error) {
|
||||||
|
return new Text(theme.fg("error", `Perplexity error: ${error}`), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isErrorText(contentText)) {
|
||||||
|
return new Text(theme.fg("error", truncate(contentText ?? "Perplexity request failed", 200)), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCount = asNumber(details.sourceCount);
|
||||||
|
const queryMs = asNumber(details.queryMs);
|
||||||
|
const model = asString(details.model)?.trim();
|
||||||
|
const uuid = asString(details.uuid)?.trim();
|
||||||
|
|
||||||
|
let text = theme.fg("success", "✓ Perplexity");
|
||||||
|
if (typeof sourceCount === "number") {
|
||||||
|
text += theme.fg("muted", ` • ${sourceCount} source${sourceCount === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
if (typeof queryMs === "number") {
|
||||||
|
text += theme.fg("dim", ` • ${(queryMs / 1000).toFixed(queryMs < 1000 ? 2 : 1)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.expanded) {
|
||||||
|
if (contentText) {
|
||||||
|
const oneLine = contentText.replace(/\s+/g, " ").trim();
|
||||||
|
if (oneLine.length > 0) {
|
||||||
|
text += `\n${theme.fg("dim", truncate(oneLine, 160))}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
text += `\n${theme.fg("dim", `model: ${model}`)}`;
|
||||||
|
}
|
||||||
|
if (uuid) {
|
||||||
|
text += `\n${theme.fg("dim", `id: ${uuid}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentText) {
|
||||||
|
text += `\n\n${truncate(contentText, 2400)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
}
|
||||||
+188
-18
@@ -1,9 +1,16 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { mergeEvent, readSseEvents } from "./stream.js";
|
import { mergeEvent, readSseEvents } from "./stream.js";
|
||||||
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
|
import type { SearchResult, StreamEvent, WebResult } from "./types.js";
|
||||||
import { SearchError } from "./types.js";
|
import { SearchError } from "./types.js";
|
||||||
|
|
||||||
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
const PERPLEXITY_ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
||||||
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
const PERPLEXITY_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0";
|
||||||
|
const MAX_BUN_STDOUT_BYTES = 50 * 1024 * 1024;
|
||||||
|
const CLOUDFLARE_HINTS = ["just a moment", "cloudflare", "cf-chl", "cf-ray"];
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -11,6 +18,12 @@ export interface SearchParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BunFetchResult {
|
||||||
|
status: number;
|
||||||
|
contentType: string | null;
|
||||||
|
bodyText: string;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrl(url: string): string {
|
function normalizeUrl(url: string): string {
|
||||||
return url.trim().replace(/\/$/, "").toLowerCase();
|
return url.trim().replace(/\/$/, "").toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -127,8 +140,45 @@ function buildRequestBody(params: SearchParams): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapHttpError(status: number): SearchError {
|
function buildRequestHeaders(jwt: string, requestId: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
Origin: "https://www.perplexity.ai",
|
||||||
|
Referer: "https://www.perplexity.ai/",
|
||||||
|
"User-Agent": PERPLEXITY_USER_AGENT,
|
||||||
|
"X-App-ApiClient": "default",
|
||||||
|
"X-App-ApiVersion": "2.18",
|
||||||
|
"X-Perplexity-Request-Reason": "submit",
|
||||||
|
"X-Request-ID": requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloudflareChallenge(status: number, contentType: string | null, bodyText: string): boolean {
|
||||||
|
if (status !== 403) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypeLower = (contentType ?? "").toLowerCase();
|
||||||
|
const bodyLower = bodyText.toLowerCase();
|
||||||
|
|
||||||
|
if (!contentTypeLower.includes("text/html")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CLOUDFLARE_HINTS.some((hint) => bodyLower.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHttpError(status: number, bodyText = "", contentType: string | null = null): SearchError {
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
|
if (isCloudflareChallenge(status, contentType, bodyText)) {
|
||||||
|
return new SearchError(
|
||||||
|
"NETWORK",
|
||||||
|
"Perplexity request was blocked by Cloudflare challenge in this runtime. Retry via Bun runtime fallback or desktop app token path.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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). Sign in to Perplexity desktop app and retry.",
|
||||||
@@ -148,6 +198,100 @@ function mapHttpError(status: number): SearchError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function streamFromText(text: string): ReadableStream<Uint8Array> {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchViaBunRuntime(
|
||||||
|
requestBody: Record<string, unknown>,
|
||||||
|
jwt: string,
|
||||||
|
requestId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<BunFetchResult> {
|
||||||
|
const script = `
|
||||||
|
const endpoint = process.env.PI_PPLX_ENDPOINT;
|
||||||
|
const token = process.env.PI_PPLX_TOKEN;
|
||||||
|
const body = JSON.parse(process.env.PI_PPLX_BODY || "{}");
|
||||||
|
const requestId = process.env.PI_PPLX_REQUEST_ID || crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: \`Bearer \${token}\`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
Origin: "https://www.perplexity.ai",
|
||||||
|
Referer: "https://www.perplexity.ai/",
|
||||||
|
"User-Agent": "${PERPLEXITY_USER_AGENT}",
|
||||||
|
"X-App-ApiClient": "default",
|
||||||
|
"X-App-ApiVersion": "2.18",
|
||||||
|
"X-Perplexity-Request-Reason": "submit",
|
||||||
|
"X-Request-ID": requestId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
status: response.status,
|
||||||
|
contentType: response.headers.get("content-type"),
|
||||||
|
bodyText: text,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
status: 0,
|
||||||
|
contentType: null,
|
||||||
|
bodyText: String(error && error.message ? error.message : error),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
"bun",
|
||||||
|
["-e", script],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: MAX_BUN_STDOUT_BYTES,
|
||||||
|
signal,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PI_PPLX_ENDPOINT: PERPLEXITY_ENDPOINT,
|
||||||
|
PI_PPLX_TOKEN: jwt,
|
||||||
|
PI_PPLX_BODY: JSON.stringify(requestBody),
|
||||||
|
PI_PPLX_REQUEST_ID: requestId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(stdout);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Bun fallback returned non-JSON output.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Bun fallback returned invalid payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parsed as Partial<BunFetchResult>;
|
||||||
|
if (typeof result.status !== "number" || typeof result.bodyText !== "string") {
|
||||||
|
throw new Error("Bun fallback response missing required fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
contentType: typeof result.contentType === "string" ? result.contentType : null,
|
||||||
|
bodyText: result.bodyText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 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,
|
||||||
@@ -155,24 +299,15 @@ export async function searchPerplexity(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
|
const requestBody = buildRequestBody(params);
|
||||||
|
const requestHeaders = buildRequestHeaders(jwt, requestId);
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(PERPLEXITY_ENDPOINT, {
|
response = await fetch(PERPLEXITY_ENDPOINT, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
Authorization: `Bearer ${jwt}`,
|
body: JSON.stringify(requestBody),
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
Origin: "https://www.perplexity.ai",
|
|
||||||
Referer: "https://www.perplexity.ai/",
|
|
||||||
"User-Agent": PERPLEXITY_USER_AGENT,
|
|
||||||
"X-App-ApiClient": "default",
|
|
||||||
"X-App-ApiVersion": "2.18",
|
|
||||||
"X-Perplexity-Request-Reason": "submit",
|
|
||||||
"X-Request-ID": requestId,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(buildRequestBody(params)),
|
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -186,18 +321,53 @@ export async function searchPerplexity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let eventStream: ReadableStream<Uint8Array> | null = null;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw mapHttpError(response.status);
|
let bodyText = "";
|
||||||
|
try {
|
||||||
|
bodyText = await response.text();
|
||||||
|
} catch {
|
||||||
|
bodyText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
|
||||||
|
if (isCloudflareChallenge(response.status, contentType, bodyText)) {
|
||||||
|
let bunResult: BunFetchResult;
|
||||||
|
try {
|
||||||
|
bunResult = await fetchViaBunRuntime(requestBody, jwt, requestId, signal);
|
||||||
|
} catch (error) {
|
||||||
|
throw new SearchError(
|
||||||
|
"NETWORK",
|
||||||
|
`Perplexity request hit Cloudflare challenge and Bun fallback failed: ${(error as Error).message || "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bunResult.status !== 200) {
|
||||||
|
throw mapHttpError(bunResult.status, bunResult.bodyText, bunResult.contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStream = streamFromText(bunResult.bodyText);
|
||||||
|
} else {
|
||||||
|
throw mapHttpError(response.status, bodyText, contentType);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
|
||||||
|
}
|
||||||
|
|
||||||
|
eventStream = response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
if (!eventStream) {
|
||||||
throw new SearchError("STREAM", "Perplexity returned an empty stream body.");
|
throw new SearchError("STREAM", "Perplexity returned no readable stream.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshot: StreamEvent = {};
|
let snapshot: StreamEvent = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const event of readSseEvents(response.body, signal)) {
|
for await (const event of readSseEvents(eventStream, signal)) {
|
||||||
snapshot = mergeEvent(snapshot, event);
|
snapshot = mergeEvent(snapshot, event);
|
||||||
if (event.final || event.status === "COMPLETED") {
|
if (event.final || event.status === "COMPLETED") {
|
||||||
break;
|
break;
|
||||||
|
|||||||
+157
-1
@@ -13,6 +13,10 @@ function createJwt(expiryMs: number): string {
|
|||||||
return `${header}.${payload}.signature`;
|
return `${header}.${payload}.signature`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createOpaqueToken(): string {
|
||||||
|
return "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.part2.part3.part4.part5";
|
||||||
|
}
|
||||||
|
|
||||||
async function importLoginModule() {
|
async function importLoginModule() {
|
||||||
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
|
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,31 @@ describe("auth/login", () => {
|
|||||||
expect(token).toBe(desktopToken);
|
expect(token).toBe(desktopToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("extractFromDesktopApp returns opaque token from defaults output", async () => {
|
||||||
|
const desktopToken = createOpaqueToken();
|
||||||
|
const execFileMock = mock((...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as (
|
||||||
|
error: Error | null,
|
||||||
|
stdout?: string,
|
||||||
|
stderr?: string,
|
||||||
|
) => void;
|
||||||
|
callback(null, `${desktopToken}\n`, "");
|
||||||
|
}) as unknown as typeof import("node:child_process").execFile;
|
||||||
|
|
||||||
|
(execFileMock as unknown as Record<symbol, unknown>)[
|
||||||
|
Symbol.for("nodejs.util.promisify.custom")
|
||||||
|
] = async () => ({ stdout: `${desktopToken}\n`, stderr: "" });
|
||||||
|
|
||||||
|
mock.module("node:child_process", () => ({
|
||||||
|
execFile: execFileMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { extractFromDesktopApp } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await extractFromDesktopApp();
|
||||||
|
expect(token).toBe(desktopToken);
|
||||||
|
});
|
||||||
|
|
||||||
test("authenticate returns non-expired cached token without desktop or OTP calls", async () => {
|
test("authenticate returns non-expired cached token without desktop or OTP calls", async () => {
|
||||||
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
||||||
const loadTokenMock = mock(async () => ({
|
const loadTokenMock = mock(async () => ({
|
||||||
@@ -133,7 +162,7 @@ describe("auth/login", () => {
|
|||||||
test("authenticate uses OTP fallback when desktop borrowing is disabled", async () => {
|
test("authenticate uses OTP fallback when desktop borrowing is disabled", async () => {
|
||||||
process.env.PI_AUTH_NO_BORROW = "1";
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
const otpToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
|
const otpToken = createOpaqueToken();
|
||||||
const loadTokenMock = mock(async () => null);
|
const loadTokenMock = mock(async () => null);
|
||||||
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
|
||||||
const clearTokenMock = mock(async () => undefined);
|
const clearTokenMock = mock(async () => undefined);
|
||||||
@@ -206,6 +235,133 @@ describe("auth/login", () => {
|
|||||||
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("authenticate accepts OTP token from session cookie when body has no token", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const otpToken = createOpaqueToken();
|
||||||
|
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 (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.endsWith("/csrf")) {
|
||||||
|
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-email")) {
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-otp")) {
|
||||||
|
return new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"set-cookie": `__Secure-next-auth.session-token=${encodeURIComponent(otpToken)}; Path=/; HttpOnly`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate({
|
||||||
|
promptForEmail: async () => "user@example.com",
|
||||||
|
promptForOtp: async () => "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token).toBe(otpToken);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticate falls back to /session when OTP body has no token", async () => {
|
||||||
|
process.env.PI_AUTH_NO_BORROW = "1";
|
||||||
|
|
||||||
|
const otpToken = createOpaqueToken();
|
||||||
|
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 (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.endsWith("/csrf")) {
|
||||||
|
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"set-cookie": "next-auth.csrf-token=csrf-cookie; Path=/",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-email")) {
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/signin-otp")) {
|
||||||
|
return new Response(JSON.stringify({ status: "ok" }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/session")) {
|
||||||
|
const cookieHeader = new Headers(init?.headers).get("Cookie") ?? "";
|
||||||
|
expect(cookieHeader).toContain("next-auth.csrf-token=csrf-cookie");
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ token: otpToken }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { authenticate } = await importLoginModule();
|
||||||
|
|
||||||
|
const token = await authenticate({
|
||||||
|
promptForEmail: async () => "user@example.com",
|
||||||
|
promptForOtp: async () => "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(token).toBe(otpToken);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||||
|
expect(saveTokenMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(clearTokenMock).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user