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:
@@ -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
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user