From 8d34d1bc2e86b40172d660d6ed15a121e6e875b5 Mon Sep 17 00:00:00 2001 From: Andrey Kuznetsov Date: Mon, 17 Aug 2026 13:31:14 +0000 Subject: [PATCH] fix(ai): accept Perplexity OTP challenge token --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/registry/oauth/perplexity.ts | 9 +- packages/ai/test/perplexity-login.test.ts | 91 ++++++++++---------- 3 files changed, 57 insertions(+), 47 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e469ca4f6..a9443c653 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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 ### Changed diff --git a/packages/ai/src/registry/oauth/perplexity.ts b/packages/ai/src/registry/oauth/perplexity.ts index 403e08c67..5fbe4fb4c 100644 --- a/packages/ai/src/registry/oauth/perplexity.ts +++ b/packages/ai/src/registry/oauth/perplexity.ts @@ -201,6 +201,7 @@ async function httpEmailLogin(ctrl: OAuthController): Promise 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 }); } - 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); } // --------------------------------------------------------------------------- diff --git a/packages/ai/test/perplexity-login.test.ts b/packages/ai/test/perplexity-login.test.ts index 825a859fe..c0b79e560 100644 --- a/packages/ai/test/perplexity-login.test.ts +++ b/packages/ai/test/perplexity-login.test.ts @@ -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", + ]), + ); + }, + ); });