Merge PR #8819: fix(ai): accept Perplexity OTP challenge token (@onsails)
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
|
||||
- Fixed OpenAI Completions, Amazon Bedrock, and Cursor providers ignoring `onPayload` replacement payloads. The hook now transforms the actual request body sent upstream on these providers, matching the Anthropic/Gemini/OpenAI Responses replacement contract. `devin-agent` still does not fire the hook (its payload is a protobuf object).
|
||||
- Fixed Codex requests failing outright when the signed-in ChatGPT account is not entitled to the requested model; the exact model denial is now classified as an account-policy error so credential rotation can reach an entitled sibling account
|
||||
- Fixed Perplexity email-OTP login after its verification response renamed the encrypted session token from `token` to `challenge_token`.
|
||||
|
||||
## [17.3.7] - 2026-08-17
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
|
||||
|
||||
const verifyData = (await verifyResponse.json()) as {
|
||||
token?: string;
|
||||
challenge_token?: string;
|
||||
status?: string;
|
||||
error_code?: string;
|
||||
text?: string;
|
||||
@@ -215,14 +216,16 @@ async function httpEmailLogin(ctrl: OAuthController): Promise<OAuthCredentials>
|
||||
});
|
||||
}
|
||||
|
||||
if (!verifyData.token) {
|
||||
throw new AIError.OAuthError("Perplexity OTP verification response missing token", {
|
||||
const token = verifyData.challenge_token || verifyData.token;
|
||||
if (!token || verifyData.error_code || (verifyData.status && verifyData.status !== "success")) {
|
||||
const reason = verifyData.text ?? verifyData.error_code ?? verifyData.status ?? "missing token";
|
||||
throw new AIError.OAuthError(`Perplexity OTP verification response rejected: ${reason}`, {
|
||||
kind: "validation",
|
||||
provider: "perplexity",
|
||||
});
|
||||
}
|
||||
|
||||
return jwtToCredentials(verifyData.token, trimmedEmail);
|
||||
return jwtToCredentials(token, trimmedEmail);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,54 +17,57 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Perplexity email OTP login", () => {
|
||||
it("replays cookies across the CSRF, email, and OTP requests", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected global fetch"));
|
||||
const requests: CapturedRequest[] = [];
|
||||
const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString());
|
||||
requests.push({ path: url.pathname, cookie: new Headers(init?.headers).get("Cookie") });
|
||||
it.each(["token", "challenge_token"] as const)(
|
||||
"replays cookies and accepts the %s OTP response field",
|
||||
async tokenField => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected global fetch"));
|
||||
const requests: CapturedRequest[] = [];
|
||||
const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString());
|
||||
requests.push({ path: url.pathname, cookie: new Headers(init?.headers).get("Cookie") });
|
||||
|
||||
if (url.pathname.endsWith("/csrf")) {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
headers.append("Set-Cookie", "next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly; Secure");
|
||||
headers.append("Set-Cookie", "__cf_bm=cloudflare-cookie; Path=/; Secure");
|
||||
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), { status: 200, headers });
|
||||
}
|
||||
if (url.pathname.endsWith("/signin-email")) {
|
||||
return new Response("{}", {
|
||||
if (url.pathname.endsWith("/csrf")) {
|
||||
const headers = new Headers({ "Content-Type": "application/json" });
|
||||
headers.append("Set-Cookie", "next-auth.csrf-token=csrf-cookie; Path=/; HttpOnly; Secure");
|
||||
headers.append("Set-Cookie", "__cf_bm=cloudflare-cookie; Path=/; Secure");
|
||||
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), { status: 200, headers });
|
||||
}
|
||||
if (url.pathname.endsWith("/signin-email")) {
|
||||
return new Response("{}", {
|
||||
status: 200,
|
||||
headers: { "Set-Cookie": "next-auth.callback-url=callback-cookie; Path=/; HttpOnly; Secure" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ [tokenField]: "perplexity-jwe", status: "success" }), {
|
||||
status: 200,
|
||||
headers: { "Set-Cookie": "next-auth.callback-url=callback-cookie; Path=/; HttpOnly; Secure" },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ token: "perplexity-jwt", status: "success" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
const answers = ["user@example.com", "123456"];
|
||||
const answers = ["user@example.com", "123456"];
|
||||
|
||||
await withEnv({ PI_AUTH_NO_BORROW: "1" }, async () => {
|
||||
const credentials = await loginPerplexity({
|
||||
fetch: fetchMock,
|
||||
onPrompt: async () => answers.shift() ?? "",
|
||||
await withEnv({ PI_AUTH_NO_BORROW: "1" }, async () => {
|
||||
const credentials = await loginPerplexity({
|
||||
fetch: fetchMock,
|
||||
onPrompt: async () => answers.shift() ?? "",
|
||||
});
|
||||
expect(credentials.access).toBe("perplexity-jwe");
|
||||
});
|
||||
expect(credentials.access).toBe("perplexity-jwt");
|
||||
});
|
||||
expect(requests.map(request => request.path)).toEqual([
|
||||
"/api/auth/csrf",
|
||||
"/api/auth/signin-email",
|
||||
"/api/auth/signin-otp",
|
||||
]);
|
||||
expect(requests[0]?.cookie).toBeNull();
|
||||
expect(cookiePairs(requests[1]?.cookie ?? null)).toEqual(
|
||||
new Set(["next-auth.csrf-token=csrf-cookie", "__cf_bm=cloudflare-cookie"]),
|
||||
);
|
||||
expect(cookiePairs(requests[2]?.cookie ?? null)).toEqual(
|
||||
new Set([
|
||||
"next-auth.csrf-token=csrf-cookie",
|
||||
"__cf_bm=cloudflare-cookie",
|
||||
"next-auth.callback-url=callback-cookie",
|
||||
]),
|
||||
);
|
||||
});
|
||||
expect(requests.map(request => request.path)).toEqual([
|
||||
"/api/auth/csrf",
|
||||
"/api/auth/signin-email",
|
||||
"/api/auth/signin-otp",
|
||||
]);
|
||||
expect(requests[0]?.cookie).toBeNull();
|
||||
expect(cookiePairs(requests[1]?.cookie ?? null)).toEqual(
|
||||
new Set(["next-auth.csrf-token=csrf-cookie", "__cf_bm=cloudflare-cookie"]),
|
||||
);
|
||||
expect(cookiePairs(requests[2]?.cookie ?? null)).toEqual(
|
||||
new Set([
|
||||
"next-auth.csrf-token=csrf-cookie",
|
||||
"__cf_bm=cloudflare-cookie",
|
||||
"next-auth.callback-url=callback-cookie",
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user