mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
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:
@@ -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);
|
||||
|
||||
78
open-sse/utils/nextAuthCookie.ts
Normal file
78
open-sse/utils/nextAuthCookie.ts
Normal 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}`;
|
||||
}
|
||||
191
tests/unit/8200-perplexity-web-cookie-rotation.test.ts
Normal file
191
tests/unit/8200-perplexity-web-cookie-rotation.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user