Simplify auth and search client, drop local JWT expiry tracking

Auth:
- Remove jwt.ts — stop decoding JWT exp claims locally
- Simplify OTP login: direct token extraction from verify response,
  remove CookieJar, BFS token extraction, session fallback, CF bypass
- Storage: drop expires field, store token-only; clear on 401/403
- Single auth path: try stored token → macOS app → OTP fallback

Search client:
- Remove Cloudflare subprocess fallback (fetchViaBunRuntime)
- Remove streamFromText, isCloudflareChallenge, BunFetchResult
- Simplify SSE fetch to single fetch() call with abort signal
- Let server validate tokens; clear cache on auth errors

Render:
- Extract shared utilities (asString, truncate) to render/util.ts
- Simplify call.ts and result.ts to import from shared module

Tests:
- Remove jwt.test.ts (module deleted)
- Add otp-flow.test.ts for email OTP authentication
- Simplify login and client tests for reduced code paths

Add debug scripts and plan documents.
This commit is contained in:
Ivan Pereira
2026-02-18 08:03:39 +00:00
parent f42531b07c
commit 501935fe1c
17 changed files with 586 additions and 772 deletions
-42
View File
@@ -1,42 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { decodeJwtExpiry, isJwtExpired } from "../../src/auth/jwt.js";
const FIXED_NOW = Date.UTC(2026, 1, 16, 12, 0, 0);
function createJwt(expSeconds: number): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString("base64url");
return `${header}.${payload}.signature`;
}
describe("jwt helpers", () => {
const originalNow = Date.now;
beforeEach(() => {
Date.now = () => FIXED_NOW;
});
afterEach(() => {
Date.now = originalNow;
});
test("decodeJwtExpiry returns expiry in ms with 5 minute safety margin", () => {
const expSeconds = Math.floor((FIXED_NOW + 2 * 60 * 60 * 1000) / 1000);
const token = createJwt(expSeconds);
expect(decodeJwtExpiry(token)).toBe(expSeconds * 1000 - 5 * 60 * 1000);
});
test("decodeJwtExpiry falls back to now + 1h when token is malformed", () => {
expect(decodeJwtExpiry("not-a-jwt")).toBe(FIXED_NOW + 60 * 60 * 1000);
});
test("isJwtExpired honors additional caller-provided buffer", () => {
const expSeconds = Math.floor((FIXED_NOW + 20 * 60 * 1000) / 1000);
const token = createJwt(expSeconds);
expect(isJwtExpired(token)).toBe(false);
expect(isJwtExpired(token, 16 * 60 * 1000)).toBe(true);
});
});
+1 -130
View File
@@ -119,12 +119,11 @@ describe("auth/login", () => {
expect(token).toBe(desktopToken);
});
test("authenticate returns non-expired cached token without desktop or OTP calls", async () => {
test("authenticate returns cached token without desktop or OTP calls", async () => {
const cachedToken = createJwt(Date.now() + 2 * 60 * 60 * 1000);
const loadTokenMock = mock(async () => ({
type: "oauth",
access: cachedToken,
expires: Date.now() + 60 * 60 * 1000,
}) satisfies StoredToken);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
@@ -234,134 +233,6 @@ describe("auth/login", () => {
expect(clearTokenMock).toHaveBeenCalledTimes(0);
});
test("authenticate accepts OTP token from session cookie when body has no token", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const otpToken = createOpaqueToken();
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/signin-otp")) {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": `__Secure-next-auth.session-token=${encodeURIComponent(otpToken)}; Path=/; HttpOnly`,
},
});
}
return new Response("not found", { status: 404 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const token = await authenticate({
promptForEmail: async () => "user@example.com",
promptForOtp: async () => "123456",
});
expect(token).toBe(otpToken);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(saveTokenMock).toHaveBeenCalledTimes(1);
});
test("authenticate falls back to /session when OTP body has no token", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const otpToken = createOpaqueToken();
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: "csrf-token" }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "next-auth.csrf-token=csrf-cookie; Path=/",
},
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/signin-otp")) {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/session")) {
const cookieHeader = new Headers(init?.headers).get("Cookie") ?? "";
expect(cookieHeader).toContain("next-auth.csrf-token=csrf-cookie");
return new Response(JSON.stringify({ token: otpToken }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("not found", { status: 404 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const token = await authenticate({
promptForEmail: async () => "user@example.com",
promptForOtp: async () => "123456",
});
expect(token).toBe(otpToken);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(saveTokenMock).toHaveBeenCalledTimes(1);
expect(clearTokenMock).toHaveBeenCalledTimes(0);
});
test("authenticate throws NO_TOKEN when no cached token and no OTP email input", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
+296
View File
@@ -0,0 +1,296 @@
/**
* OTP login flow tests derived from real captured request/response data.
* See scripts/debug-login-dump.json for the raw fixture.
*/
import { afterEach, describe, expect, mock, test } from "bun:test";
import { AuthError, type StoredToken } from "../../src/search/types.js";
const originalFetch = globalThis.fetch;
const originalBorrow = process.env.PI_AUTH_NO_BORROW;
const originalEmail = process.env.PI_PERPLEXITY_EMAIL;
const originalOtp = process.env.PI_PERPLEXITY_OTP;
// --- Fixtures from real Perplexity responses (scripts/debug-login-dump.json) ---
/** Real JWE token structure: alg=dir, enc=A256GCM — NOT a JWT, opaque to us */
const REAL_JWE_TOKEN =
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..AAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.AAAAAAAAAAAAAAAAAAAA";
const CSRF_TOKEN = "0e4f8cc491e3197788492604ad32577f2022747fe30e0f51ba3ba235f07cc9ee";
const TEST_EMAIL = "user@test.com";
const TEST_OTP = "9f3e2-knzol";
// ---
async function importLoginModule() {
return import(`../../src/auth/login.ts?test=${crypto.randomUUID()}`);
}
function restoreEnv(): void {
for (const [key, original] of [
["PI_AUTH_NO_BORROW", originalBorrow],
["PI_PERPLEXITY_EMAIL", originalEmail],
["PI_PERPLEXITY_OTP", originalOtp],
] as const) {
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
function mockStorage() {
const loadTokenMock = mock(async () => null);
const saveTokenMock = mock(async (_token: StoredToken) => undefined);
const clearTokenMock = mock(async () => undefined);
mock.module("../../src/auth/storage.js", () => ({
loadToken: loadTokenMock,
saveToken: saveTokenMock,
clearToken: clearTokenMock,
}));
return { loadTokenMock, saveTokenMock, clearTokenMock };
}
/** Build a fetch mock that replays real Perplexity response shapes. */
function buildReplayFetchMock(options?: {
/** Override the OTP response body (default: real token+status response) */
otpResponseBody?: unknown;
}) {
const calls: { url: string; init?: RequestInit }[] = [];
const otpBody = options?.otpResponseBody ?? { token: REAL_JWE_TOKEN, status: "success" };
const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const entry: { url: string; init?: RequestInit } = { url };
if (init !== undefined) entry.init = init;
calls.push(entry);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
if (url.endsWith("/signin-otp")) {
return new Response(JSON.stringify(otpBody), {
status: 200,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
return new Response("not found", { status: 404 });
});
return { fetchMock, calls };
}
afterEach(() => {
mock.restore();
globalThis.fetch = originalFetch;
restoreEnv();
});
describe("OTP login flow (from real captured responses)", () => {
test("full flow: CSRF → email → OTP, extracts JWE token from response body", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
const { saveTokenMock } = mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const token = await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
expect(token).toBe(REAL_JWE_TOKEN);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(saveTokenMock).toHaveBeenCalledTimes(1);
const saved = saveTokenMock.mock.calls[0]?.[0] as StoredToken;
expect(saved.type).toBe("oauth");
expect(saved.access).toBe(REAL_JWE_TOKEN);
});
test("exactly 3 requests: no /session fallback when token is in body", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock, calls } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
expect(calls).toHaveLength(3);
expect(calls[0].url).toContain("/csrf");
expect(calls[1].url).toContain("/signin-email");
expect(calls[2].url).toContain("/signin-otp");
});
test("request bodies match expected shape", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock, calls } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
// CSRF is GET, no body
expect(calls[0].init?.method ?? "GET").toBe("GET");
expect(calls[0].init?.body).toBeFalsy();
// signin-email: POST with email + csrfToken
expect(calls[1].init?.method).toBe("POST");
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
email: TEST_EMAIL,
csrfToken: CSRF_TOKEN,
});
// signin-otp: POST with email + otp + csrfToken
expect(calls[2].init?.method).toBe("POST");
expect(JSON.parse(String(calls[2].init?.body))).toEqual({
email: TEST_EMAIL,
otp: TEST_OTP,
csrfToken: CSRF_TOKEN,
});
});
test("env vars PI_PERPLEXITY_EMAIL and PI_PERPLEXITY_OTP bypass prompts", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
process.env.PI_PERPLEXITY_EMAIL = TEST_EMAIL;
process.env.PI_PERPLEXITY_OTP = TEST_OTP;
mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
const promptForEmail = mock(async () => "should-not-be-called@test.com");
const promptForOtp = mock(async () => "should-not-be-called");
const token = await authenticate({ promptForEmail, promptForOtp });
expect(token).toBe(REAL_JWE_TOKEN);
expect(promptForEmail).toHaveBeenCalledTimes(0);
expect(promptForOtp).toHaveBeenCalledTimes(0);
});
test("throws AuthError NO_TOKEN when email prompt returns undefined", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => undefined,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
});
test("throws AuthError NO_TOKEN when OTP prompt returns undefined", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const { fetchMock } = buildReplayFetchMock();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => undefined,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("NO_TOKEN");
});
test("throws when OTP verification returns non-200", async () => {
process.env.PI_AUTH_NO_BORROW = "1";
mockStorage();
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/csrf")) {
return new Response(JSON.stringify({ csrfToken: CSRF_TOKEN }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/signin-email")) {
return new Response(JSON.stringify({ success: "Email sign in triggered" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.endsWith("/signin-otp")) {
return new Response("Unauthorized", { status: 401 });
}
return new Response("not found", { status: 404 });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const { authenticate } = await importLoginModule();
let thrown: unknown;
try {
await authenticate({
promptForEmail: async () => TEST_EMAIL,
promptForOtp: async () => TEST_OTP,
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AuthError);
expect((thrown as AuthError).code).toBe("EXTRACTION_FAILED");
expect((thrown as AuthError).message).toContain("OTP verification failed");
});
});
+9 -42
View File
@@ -3,8 +3,6 @@ import { afterEach, describe, expect, test } from "bun:test";
import { searchPerplexity } from "../../src/search/client.js";
import { SearchError } from "../../src/search/types.js";
const ENDPOINT = "https://www.perplexity.ai/rest/sse/perplexity_ask";
function createSseResponse(events: Array<Record<string, unknown>>, status = 200): Response {
const streamText = [
...events.map((event) => `data: ${JSON.stringify(event)}\n\n`),
@@ -13,9 +11,7 @@ function createSseResponse(events: Array<Record<string, unknown>>, status = 200)
return new Response(streamText, {
status,
headers: {
"content-type": "text/event-stream",
},
headers: { "content-type": "text/event-stream" },
});
}
@@ -39,22 +35,12 @@ describe("searchPerplexity", () => {
status: "COMPLETED",
final: true,
blocks: [
{
intended_usage: "markdown_block",
markdown_block: {
answer: "answer text",
},
},
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
{
intended_usage: "web_results",
web_result_block: {
web_results: [
{
name: "Source",
url: "https://example.com",
snippet: "snippet",
timestamp: "2026-02-16T10:00:00.000Z",
},
{ name: "Source", url: "https://example.com", snippet: "snippet", timestamp: "2026-02-16T10:00:00.000Z" },
],
},
},
@@ -70,7 +56,7 @@ describe("searchPerplexity", () => {
controller.signal,
);
expect(String(capturedUrl)).toBe(ENDPOINT);
expect(String(capturedUrl)).toBe("https://www.perplexity.ai/rest/sse/perplexity_ask");
expect(capturedInit?.method).toBe("POST");
expect(capturedInit?.signal).toBe(controller.signal);
@@ -133,12 +119,7 @@ describe("searchPerplexity", () => {
status: "COMPLETED",
final: true,
blocks: [
{
intended_usage: "markdown_block",
markdown_block: {
answer: "answer text",
},
},
{ intended_usage: "markdown_block", markdown_block: { answer: "answer text" } },
{
intended_usage: "web_results",
web_result_block: {
@@ -168,14 +149,8 @@ describe("searchPerplexity", () => {
final: true,
text: "fallback text",
blocks: [
{
intended_usage: "ask_text",
markdown_block: { answer: "ask text" },
},
{
intended_usage: "markdown_block",
markdown_block: { answer: "markdown answer" },
},
{ intended_usage: "ask_text", markdown_block: { answer: "ask text" } },
{ intended_usage: "markdown_block", markdown_block: { answer: "markdown answer" } },
],
sources_list: [{ title: "S", url: "https://example.com" }],
},
@@ -193,10 +168,7 @@ describe("searchPerplexity", () => {
final: true,
text: "fallback text",
blocks: [
{
intended_usage: "ask_text",
markdown_block: { answer: "ask answer" },
},
{ intended_usage: "ask_text", markdown_block: { answer: "ask answer" } },
],
sources_list: [{ title: "S", url: "https://example.com" }],
},
@@ -221,12 +193,7 @@ describe("searchPerplexity", () => {
test("returns EMPTY error when response has no answer and no sources", async () => {
globalThis.fetch = (async () =>
createSseResponse([
{
status: "COMPLETED",
final: true,
},
])) as unknown as typeof fetch;
createSseResponse([{ status: "COMPLETED", final: true }])) as unknown as typeof fetch;
let thrown: unknown;
try {