diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 3594e15eb..70d276264 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -69,7 +69,7 @@ import { useMessagesSync } from "@/hooks/use-messages-sync"; import { useThreadDetail, useThreadMessages } from "@/hooks/use-thread-queries"; import { getAgentFilesystemSelection } from "@/lib/agent-filesystem"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { getBearerToken } from "@/lib/auth-utils"; +import { getDesktopAccessToken } from "@/lib/auth-fetch"; import { type ChatFlow, classifyChatError } from "@/lib/chat/chat-error-classifier"; import { tagPreAcceptSendFailure, toHttpResponseError } from "@/lib/chat/chat-request-errors"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; @@ -917,29 +917,26 @@ export default function NewChatPage() { // Cancel ongoing request const cancelRun = useCallback(async () => { if (threadId) { - const token = getBearerToken(); - if (token) { - try { - const response = await fetch( - buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`), - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - if (response.ok) { - const payload = (await response.json()) as { - error_code?: string; - }; - if (payload.error_code === "TURN_CANCELLING") { - recentCancelRequestedAtRef.current = Date.now(); - } + const token = await getDesktopAccessToken(); + try { + const response = await fetch( + buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`), + { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: "include", + } + ); + if (response.ok) { + const payload = (await response.json()) as { + error_code?: string; + }; + if (payload.error_code === "TURN_CANCELLING") { + recentCancelRequestedAtRef.current = Date.now(); } - } catch (error) { - console.warn("[NewChatPage] Failed to signal cancel-active-turn:", error); } + } catch (error) { + console.warn("[NewChatPage] Failed to signal cancel-active-turn:", error); } } if (abortControllerRef.current) { @@ -964,11 +961,7 @@ export default function NewChatPage() { if (!userQuery.trim() && userImages.length === 0) return; - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - return; - } + const token = await getDesktopAccessToken(); // Lazy thread creation: create thread on first message if it doesn't exist let currentThreadId = threadId; @@ -1149,8 +1142,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify({ chat_id: currentThreadId, user_query: userQuery.trim(), @@ -1537,12 +1531,7 @@ export default function NewChatPage() { stagedDecisionsByInterruptIdRef.current.clear(); setIsRunning(true); - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - setIsRunning(false); - return; - } + const token = await getDesktopAccessToken(); const controller = new AbortController(); abortControllerRef.current = controller; @@ -1648,8 +1637,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify({ search_space_id: searchSpaceId, decisions, @@ -1981,11 +1971,7 @@ export default function NewChatPage() { abortControllerRef.current = null; } - const token = getBearerToken(); - if (!token) { - toast.error("Not authenticated. Please log in again."); - return; - } + const token = await getDesktopAccessToken(); // Extract the original user query BEFORE removing messages (for reload mode) let userQueryToDisplay: string | undefined; @@ -2104,8 +2090,9 @@ export default function NewChatPage() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), }, + credentials: "include", body: JSON.stringify(requestBody), signal: controller.signal, }) diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx index 4a3c5e9e7..b1e01c273 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx @@ -19,7 +19,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import type { SearchSpace } from "@/contracts/types/search-space.types"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/tests/auth.setup.ts b/surfsense_web/tests/auth.setup.ts index 7c1e37a39..c7c8bce4f 100644 --- a/surfsense_web/tests/auth.setup.ts +++ b/surfsense_web/tests/auth.setup.ts @@ -4,9 +4,9 @@ import { announcements } from "../lib/announcements/announcements-data"; import { acquireTestToken } from "./helpers/api/auth"; /** - * One-time authentication setup. Acquires a bearer token for the seeded - * e2e user (rate-limit-free /__e2e__/auth/token first, /auth/jwt/login - * fallback) and persists it via localStorage so every test in the + * One-time authentication setup. Acquires an access token for the seeded + * e2e user (rate-limit-free /__e2e__/auth/token first, desktop login + * fallback) and persists it as the session cookie so every test in the * chromium project starts already authenticated. * * Also pre-seeds the localStorage flags that gate the two new-user UI @@ -18,7 +18,9 @@ import { acquireTestToken } from "./helpers/api/auth"; const authFile = path.join(__dirname, "..", "playwright", ".auth", "user.json"); -const STORAGE_KEY = "surfsense_bearer_token"; +const PORT = process.env.PORT || "3000"; +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`; +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "surfsense_session"; const ANNOUNCEMENTS_KEY = "surfsense_announcements_state"; /** Decode the user id (`sub`) from a JWT without verifying the signature. */ @@ -45,17 +47,24 @@ setup("authenticate", async ({ page, request }) => { const announcementIds = announcements.map((a) => a.id); const announcementState = { readIds: announcementIds, toastedIds: announcementIds }; + await page.context().addCookies([ + { + name: SESSION_COOKIE_NAME, + value: access_token, + url: BASE_URL, + httpOnly: true, + sameSite: "Lax", + }, + ]); + await page.addInitScript( - ({ key, token, announcementsKey, state, uid }) => { - localStorage.setItem(key, token); + ({ announcementsKey, state, uid }) => { localStorage.setItem(announcementsKey, JSON.stringify(state)); if (uid) { localStorage.setItem(`surfsense-tour-${uid}`, "true"); } }, { - key: STORAGE_KEY, - token: access_token, announcementsKey: ANNOUNCEMENTS_KEY, state: announcementState, uid: userId, diff --git a/surfsense_web/tests/fixtures/search-space.fixture.ts b/surfsense_web/tests/fixtures/search-space.fixture.ts index 62958caf4..e68ff6dce 100644 --- a/surfsense_web/tests/fixtures/search-space.fixture.ts +++ b/surfsense_web/tests/fixtures/search-space.fixture.ts @@ -22,26 +22,21 @@ export type SearchSpaceFixtures = { searchSpace: SearchSpaceRow; }; -const STORAGE_KEY = "surfsense_bearer_token"; +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "surfsense_session"; -// Reuse the token written by tests/auth.setup.ts; on cache miss we +// Reuse the session cookie written by tests/auth.setup.ts; on cache miss we // mint a fresh one via /__e2e__/auth/token (rate-limit-free). const AUTH_STATE_PATH = path.join(__dirname, "..", "..", "playwright", ".auth", "user.json"); -function loadCachedBearerToken(): string | null { +function loadCachedSessionToken(): string | null { try { const raw = fs.readFileSync(AUTH_STATE_PATH, "utf8"); const parsed = JSON.parse(raw) as { - origins?: Array<{ - origin?: string; - localStorage?: Array<{ name?: string; value?: string }>; - }>; + cookies?: Array<{ name?: string; value?: string }>; }; - for (const origin of parsed.origins ?? []) { - for (const entry of origin.localStorage ?? []) { - if (entry.name === STORAGE_KEY && entry.value) { - return entry.value; - } + for (const cookie of parsed.cookies ?? []) { + if (cookie.name === SESSION_COOKIE_NAME && cookie.value) { + return cookie.value; } } } catch { @@ -53,7 +48,7 @@ function loadCachedBearerToken(): string | null { export const searchSpaceFixtures = base.extend({ apiTokenWorker: [ async ({ playwright }, use) => { - const cached = loadCachedBearerToken(); + const cached = loadCachedSessionToken(); if (cached) { await use(cached); return; diff --git a/surfsense_web/tests/helpers/api/auth.ts b/surfsense_web/tests/helpers/api/auth.ts index 6492b09ba..845e868f1 100644 --- a/surfsense_web/tests/helpers/api/auth.ts +++ b/surfsense_web/tests/helpers/api/auth.ts @@ -1,11 +1,11 @@ import type { APIRequestContext } from "@playwright/test"; /** - * Direct backend auth helper. Uses the same /auth/jwt/login endpoint the - * UI uses; mirrors lib/apis/auth-api.service.ts. + * Direct backend auth helper. Uses the desktop login endpoint when the + * rate-limit-free e2e mint endpoint is unavailable. * * Returns a bearer token specs can attach to API calls when they don't - * want to go through the browser. The browser-side auth (localStorage) + * want to go through the browser. The browser-side auth (cookie storage) * is set up separately by tests/auth.setup.ts. */ @@ -18,7 +18,7 @@ const E2E_MINT_SECRET = process.env.E2E_MINT_SECRET || "local-e2e-mint-secret-no /** * Mints a JWT for the seeded e2e user via the test-only endpoint mounted * by surfsense_backend/tests/e2e/run_backend.py. Bypasses the production - * /auth/jwt/login rate limit (5/min/IP), so it's safe to call from any + * desktop login rate limit, so it's safe to call from any * worker / retry. Returns 404 from the backend when the endpoint isn't * mounted (i.e. someone is pointing the suite at a non-e2e backend). */ @@ -46,18 +46,17 @@ export async function mintTestToken( } export async function loginAsTestUser(request: APIRequestContext): Promise { - const response = await request.post(`${BACKEND_URL}/auth/jwt/login`, { - form: { - username: TEST_USER_EMAIL, + const response = await request.post(`${BACKEND_URL}/auth/desktop/login`, { + data: { + email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD, - grant_type: "password", }, - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { "Content-Type": "application/json" }, }); if (!response.ok()) { throw new Error( - `Login to ${BACKEND_URL}/auth/jwt/login failed (${response.status()}): ${await response.text()}` + `Login to ${BACKEND_URL}/auth/desktop/login failed (${response.status()}): ${await response.text()}` ); } @@ -70,7 +69,7 @@ export async function loginAsTestUser(request: APIRequestContext): Promise {