fix(providers): persist perplexity-web Set-Cookie session rotations (#8200) (#8588)

Wire NextAuth session-token merge/persist on successful perplexity-web
responses so rotated cookies survive in provider_connections, matching
chatgpt-web behavior.
This commit is contained in:
Prudhvi Vuda
2026-07-27 18:07:13 -04:00
committed by GitHub
parent 4d24c0c4de
commit 92c18a1440
3 changed files with 311 additions and 4 deletions

View File

@@ -6,7 +6,7 @@
* completions format and Perplexity's internal protocol.
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import {
tlsFetchPerplexity,
isCloudflareChallenge,
@@ -16,6 +16,10 @@ import {
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import {
buildSessionCookieHeader,
mergeRefreshedCookie,
} from "../utils/nextAuthCookie.ts";
import {
PPLX_SSE_ENDPOINT,
PPLX_USER_AGENT,
@@ -330,6 +334,27 @@ async function buildNonStreamingResponse(
);
}
async function persistRotatedSessionCookie(
cookie: string,
setCookieHeader: string | null,
credentials: ProviderCredentials,
onCredentialsRefreshed: ExecuteInput["onCredentialsRefreshed"],
log: ExecuteInput["log"]
): Promise<void> {
if (!onCredentialsRefreshed) return;
try {
const refreshed = mergeRefreshedCookie(cookie, setCookieHeader);
if (refreshed && refreshed !== cookie) {
await onCredentialsRefreshed({ ...credentials, apiKey: refreshed });
}
} catch (err) {
log?.warn?.(
"PPLX-WEB",
`Failed to persist refreshed cookie: ${err instanceof Error ? err.message : String(err)}`
);
}
}
// ─── Executor ───────────────────────────────────────────────────────────────
export class PerplexityWebExecutor extends BaseExecutor {
@@ -337,7 +362,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
super("perplexity-web", { id: "perplexity-web", baseUrl: PPLX_SSE_ENDPOINT });
}
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
async execute({ model, body, stream, credentials, signal, log, onCredentialsRefreshed }: ExecuteInput) {
const bodyObj = (body || {}) as Record<string, unknown>;
const rawMessages = bodyObj.messages as Array<Record<string, unknown>> | undefined;
if (!rawMessages || !Array.isArray(rawMessages) || rawMessages.length === 0) {
@@ -417,10 +442,11 @@ export class PerplexityWebExecutor extends BaseExecutor {
"x-request-id": requestId,
};
const cookieBlob = credentials.apiKey ?? "";
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} else if (credentials.apiKey) {
headers["Cookie"] = `__Secure-next-auth.session-token=${credentials.apiKey}`;
} else if (cookieBlob) {
headers["Cookie"] = buildSessionCookieHeader(cookieBlob);
}
log?.info?.(
@@ -528,6 +554,18 @@ export class PerplexityWebExecutor extends BaseExecutor {
return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody };
}
// Surface any rotated session-token back to the caller so the DB credential
// is refreshed — mirrors chatgpt-web.ts exchangeSession + onCredentialsRefreshed.
if (cookieBlob) {
await persistRotatedSessionCookie(
cookieBlob,
response.headers.get("set-cookie"),
credentials,
onCredentialsRefreshed,
log
);
}
// Build OpenAI-compatible response
const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`;
const created = Math.floor(Date.now() / 1000);

View File

@@ -0,0 +1,78 @@
/**
* NextAuth session-token cookie helpers shared by web-cookie providers that
* authenticate via __Secure-next-auth.session-token (chatgpt-web, perplexity-web, …).
*
* Rotation can change the shape (unchunked → chunked or vice versa). When that
* happens, every old family member must be dropped — keeping the stale variant
* alongside the new one would send both, and depending on parser precedence the
* server could read the stale value and fail auth.
*/
export const SESSION_TOKEN_FAMILY_RE = /^__Secure-next-auth\.session-token(?:\.\d+)?$/;
/**
* Merge any rotated session-token chunks from a Set-Cookie response into the
* original cookie blob, preserving every other cookie the caller pasted
* (cf_clearance, __cf_bm, _cfuvid, …). Returns null if no rotation occurred
* or the rotated chunks match what's already there.
*/
export function mergeRefreshedCookie(
originalCookie: string,
setCookieHeader: string | null
): string | null {
if (!setCookieHeader) return null;
const matches = Array.from(
setCookieHeader.matchAll(/(__Secure-next-auth\.session-token(?:\.\d+)?)=([^;,\s]+)/g)
);
if (matches.length === 0) return null;
const refreshed = new Map<string, string>();
for (const m of matches) refreshed.set(m[1], m[2]);
let blob = originalCookie.trim();
if (/^cookie\s*:\s*/i.test(blob)) blob = blob.replace(/^cookie\s*:\s*/i, "");
// Bare value (no `=`): the original was just the session-token contents.
if (!/=/.test(blob)) {
return Array.from(refreshed, ([k, v]) => `${k}=${v}`).join("; ");
}
const pairs = blob.split(/;\s*/).filter(Boolean);
const result: string[] = [];
let mutated = false;
let droppedStale = false;
for (const pair of pairs) {
const eqIdx = pair.indexOf("=");
if (eqIdx < 0) {
result.push(pair);
continue;
}
const name = pair.slice(0, eqIdx).trim();
const value = pair.slice(eqIdx + 1);
if (SESSION_TOKEN_FAMILY_RE.test(name)) {
if (!refreshed.has(name) || refreshed.get(name) !== value) mutated = true;
droppedStale = true;
continue;
}
result.push(`${name}=${value}`);
}
for (const [name, value] of refreshed) {
result.push(`${name}=${value}`);
}
if (!droppedStale) mutated = true;
return mutated ? result.join("; ") : null;
}
/**
* Build the Cookie header value from whatever the user pasted.
*
* Accepts bare values, unchunked/chunked cookie lines, and full DevTools
* "Cookie: …" headers.
*/
export function buildSessionCookieHeader(rawInput: string): string {
let s = rawInput.trim();
if (/^cookie\s*:\s*/i.test(s)) s = s.replace(/^cookie\s*:\s*/i, "");
if (/__Secure-next-auth\.session-token(?:\.\d+)?\s*=/.test(s)) {
return s;
}
return `__Secure-next-auth.session-token=${s}`;
}

View File

@@ -0,0 +1,191 @@
// Issue #8200: perplexity-web must persist Set-Cookie session-token rotations
// via onCredentialsRefreshed — chatgpt-web parity (mergeRefreshedCookie +
// buildSessionCookieHeader in open-sse/utils/nextAuthCookie.ts).
import test from "node:test";
import assert from "node:assert/strict";
const {
mergeRefreshedCookie,
buildSessionCookieHeader,
} = await import("../../open-sse/utils/nextAuthCookie.ts");
const { PerplexityWebExecutor } = await import("../../open-sse/executors/perplexity-web.ts");
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/perplexityTlsClient.ts");
function mockPplxStream(answer = "Hello, world!") {
const encoder = new TextEncoder();
const body =
`event: message\r\ndata: ${JSON.stringify({
backend_uuid: "uuid-8200",
blocks: [
{
intended_usage: "markdown",
markdown_block: { chunks: [answer], progress: "DONE" },
},
],
status: "COMPLETED",
})}\r\n\r\n` + "event: end_of_stream\r\n\r\n";
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(body));
controller.close();
},
});
}
test("nextAuthCookie: buildSessionCookieHeader passes full DevTools cookie blob verbatim", () => {
const blob =
"__Secure-next-auth.session-token.0=partA; __Secure-next-auth.session-token.1=partB; cf_clearance=CF";
assert.equal(buildSessionCookieHeader(blob), blob);
assert.equal(
buildSessionCookieHeader(`Cookie: ${blob}`),
blob
);
});
test("nextAuthCookie: buildSessionCookieHeader wraps bare session-token value", () => {
assert.equal(
buildSessionCookieHeader("eyJhbGciOiJIUzI1NiJ9"),
"__Secure-next-auth.session-token=eyJhbGciOiJIUzI1NiJ9"
);
});
test("nextAuthCookie: mergeRefreshedCookie preserves cf_clearance and drops stale chunks", () => {
const merged = mergeRefreshedCookie(
"__Secure-next-auth.session-token.0=OLD0; __Secure-next-auth.session-token.1=OLD1; cf_clearance=CFCLEAR",
"__Secure-next-auth.session-token.0=NEW0; Path=/; HttpOnly, __Secure-next-auth.session-token.1=NEW1; Path=/; HttpOnly"
);
assert.ok(merged);
assert.match(merged, /session-token\.0=NEW0/);
assert.match(merged, /session-token\.1=NEW1/);
assert.match(merged, /cf_clearance=CFCLEAR/);
assert.doesNotMatch(merged, /OLD0/);
assert.doesNotMatch(merged, /OLD1/);
});
test("#8200: PerplexityWebExecutor persists rotated session-token via onCredentialsRefreshed", async () => {
const captured: { cookie?: string; headers?: Record<string, string> } = {};
let persisted: Record<string, unknown> | null = null;
__setTlsFetchOverrideForTesting(async (_url, opts) => {
captured.cookie = opts.headers?.Cookie as string | undefined;
captured.headers = opts.headers as Record<string, string>;
const headers = new Headers({
"Content-Type": "text/event-stream",
"set-cookie":
"__Secure-next-auth.session-token=ROTATED-VALUE; Path=/; HttpOnly; Secure",
});
return {
status: 200,
headers,
text: null,
body: mockPplxStream(),
};
});
try {
const executor = new PerplexityWebExecutor();
const result = await executor.execute({
model: "pplx-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
credentials: { apiKey: "old-cookie-value" },
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: async (creds) => {
persisted = creds as Record<string, unknown>;
},
});
assert.equal(result.response.status, 200);
assert.equal(
captured.cookie,
"__Secure-next-auth.session-token=old-cookie-value",
"bare apiKey must be normalized for the upstream Cookie header"
);
assert.ok(persisted, "onCredentialsRefreshed must fire when Set-Cookie rotates the session token");
assert.equal(
persisted.apiKey,
"__Secure-next-auth.session-token=ROTATED-VALUE",
"rotated cookie must round-trip as a full cookie line for DB persistence"
);
} finally {
__setTlsFetchOverrideForTesting(null);
}
});
test("#8200: PerplexityWebExecutor preserves cf_clearance when session-token rotates", async () => {
let persisted: Record<string, unknown> | null = null;
__setTlsFetchOverrideForTesting(async () => {
const headers = new Headers({
"Content-Type": "text/event-stream",
"set-cookie":
"__Secure-next-auth.session-token.0=NEW0; Path=/; HttpOnly, " +
"__Secure-next-auth.session-token.1=NEW1; Path=/; HttpOnly",
});
return {
status: 200,
headers,
text: null,
body: mockPplxStream(),
};
});
try {
const executor = new PerplexityWebExecutor();
await executor.execute({
model: "pplx-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
credentials: {
apiKey:
"__Secure-next-auth.session-token=UNCHUNKED_OLD; cf_clearance=CFCLEAR",
},
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: async (creds) => {
persisted = creds as Record<string, unknown>;
},
});
assert.ok(persisted);
const apiKey = String(persisted.apiKey);
assert.match(apiKey, /cf_clearance=CFCLEAR/, "non-session cookies must survive rotation merge");
assert.match(apiKey, /session-token\.0=NEW0/);
assert.match(apiKey, /session-token\.1=NEW1/);
assert.doesNotMatch(apiKey, /UNCHUNKED_OLD/);
} finally {
__setTlsFetchOverrideForTesting(null);
}
});
test("#8200: PerplexityWebExecutor skips onCredentialsRefreshed when Set-Cookie is absent", async () => {
let persisted = false;
__setTlsFetchOverrideForTesting(async () => ({
status: 200,
headers: new Headers({ "Content-Type": "text/event-stream" }),
text: null,
body: mockPplxStream(),
}));
try {
const executor = new PerplexityWebExecutor();
await executor.execute({
model: "pplx-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
credentials: { apiKey: "stable-cookie" },
signal: AbortSignal.timeout(10_000),
log: null,
onCredentialsRefreshed: async () => {
persisted = true;
},
});
assert.equal(persisted, false, "callback must not fire without a session-token rotation");
} finally {
__setTlsFetchOverrideForTesting(null);
}
});