From 92c18a1440df74200493e32ff206660f3eaff245 Mon Sep 17 00:00:00 2001 From: Prudhvi Vuda <53619858+Prudhvivuda@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:07:13 -0400 Subject: [PATCH] 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. --- open-sse/executors/perplexity-web.ts | 46 ++++- open-sse/utils/nextAuthCookie.ts | 78 +++++++ ...200-perplexity-web-cookie-rotation.test.ts | 191 ++++++++++++++++++ 3 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 open-sse/utils/nextAuthCookie.ts create mode 100644 tests/unit/8200-perplexity-web-cookie-rotation.test.ts diff --git a/open-sse/executors/perplexity-web.ts b/open-sse/executors/perplexity-web.ts index 41d1d53379..5c328f94ad 100644 --- a/open-sse/executors/perplexity-web.ts +++ b/open-sse/executors/perplexity-web.ts @@ -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 { + 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; const rawMessages = bodyObj.messages as Array> | 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); diff --git a/open-sse/utils/nextAuthCookie.ts b/open-sse/utils/nextAuthCookie.ts new file mode 100644 index 0000000000..64a4a1846d --- /dev/null +++ b/open-sse/utils/nextAuthCookie.ts @@ -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(); + 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}`; +} diff --git a/tests/unit/8200-perplexity-web-cookie-rotation.test.ts b/tests/unit/8200-perplexity-web-cookie-rotation.test.ts new file mode 100644 index 0000000000..4d23beb2ac --- /dev/null +++ b/tests/unit/8200-perplexity-web-cookie-rotation.test.ts @@ -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 } = {}; + let persisted: Record | null = null; + + __setTlsFetchOverrideForTesting(async (_url, opts) => { + captured.cookie = opts.headers?.Cookie as string | undefined; + captured.headers = opts.headers as Record; + 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; + }, + }); + + 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 | 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; + }, + }); + + 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); + } +});