diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 199b9b579..405718f0c 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -916,7 +916,7 @@ class Config: # JWT Token Lifetimes ACCESS_TOKEN_LIFETIME_SECONDS = int( - os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(30 * 60)) # 30 minutes + os.getenv("ACCESS_TOKEN_LIFETIME_SECONDS", str(60 * 60)) # 60 minutes ) MIN_ISSUED_AT = int(os.getenv("MIN_ISSUED_AT", "0")) REFRESH_TOKEN_LIFETIME_SECONDS = int( 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 9c3a7c617..64971c3bb 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 @@ -71,7 +71,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 { getDesktopAccessToken } from "@/lib/auth-fetch"; +import { authenticatedFetch } 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"; @@ -932,14 +932,11 @@ export default function NewChatPage() { // Cancel ongoing request const cancelRun = useCallback(async () => { if (threadId) { - const token = await getDesktopAccessToken(); try { - const response = await fetch( + const response = await authenticatedFetch( buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`), { method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - credentials: "include", } ); if (response.ok) { @@ -986,8 +983,6 @@ export default function NewChatPage() { if (!userQuery.trim() && userImages.length === 0) return; - const token = await getDesktopAccessToken(); - // Lazy thread creation: create thread on first message if it doesn't exist let currentThreadId = threadId; let isNewThread = false; @@ -1159,13 +1154,11 @@ export default function NewChatPage() { const hasThreadIds = mentionPayload.thread_ids.length > 0; const response = await fetchWithTurnCancellingRetry(() => - fetch(buildBackendUrl("/api/v1/new_chat"), { + authenticatedFetch(buildBackendUrl("/api/v1/new_chat"), { method: "POST", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - credentials: "include", body: JSON.stringify({ chat_id: currentThreadId, user_query: userQuery.trim(), @@ -1546,8 +1539,6 @@ export default function NewChatPage() { stagedDecisionsByInterruptIdRef.current.clear(); setIsRunning(true); - const token = await getDesktopAccessToken(); - const controller = new AbortController(); abortControllerRef.current = controller; @@ -1648,13 +1639,11 @@ export default function NewChatPage() { localFilesystemEnabled, }); const response = await fetchWithTurnCancellingRetry(() => - fetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), { + authenticatedFetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), { method: "POST", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - credentials: "include", body: JSON.stringify({ search_space_id: searchSpaceId, decisions, @@ -1986,8 +1975,6 @@ export default function NewChatPage() { abortControllerRef.current = null; } - const token = await getDesktopAccessToken(); - // Extract the original user query BEFORE removing messages (for reload mode) let userQueryToDisplay: string | undefined; let originalUserMessageContent: ThreadMessageLike["content"] | null = null; @@ -2105,13 +2092,11 @@ export default function NewChatPage() { } } const response = await fetchWithTurnCancellingRetry(() => - fetch(getRegenerateUrl(threadId), { + authenticatedFetch(getRegenerateUrl(threadId), { method: "POST", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - credentials: "include", body: JSON.stringify(requestBody), signal: controller.signal, }) diff --git a/surfsense_web/components/providers/ZeroProvider.tsx b/surfsense_web/components/providers/ZeroProvider.tsx index 1a157a854..cfb8fe586 100644 --- a/surfsense_web/components/providers/ZeroProvider.tsx +++ b/surfsense_web/components/providers/ZeroProvider.tsx @@ -8,7 +8,7 @@ import { import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { useSession } from "@/hooks/use-session"; -import { getDesktopAccessToken } from "@/lib/auth-fetch"; +import { authenticatedFetch, getDesktopAccessToken } from "@/lib/auth-fetch"; import { handleUnauthorized, isPublicRoute, refreshSession } from "@/lib/auth-utils"; import { buildBackendUrl } from "@/lib/env-config"; import type { Context } from "@/types/zero"; @@ -31,26 +31,14 @@ function getCacheURL() { } async function fetchZeroContext(isDesktop: boolean): Promise { - const headers: HeadersInit = {}; - let desktopAuth: string | undefined; - - if (isDesktop) { - const token = await getDesktopAccessToken(); - if (!token) return null; - desktopAuth = token; - headers.Authorization = `Bearer ${token}`; - } - - const response = await fetch(buildBackendUrl("/zero/context"), { - credentials: "include", - headers, + const response = await authenticatedFetch(buildBackendUrl("/zero/context"), { + skipAuthRedirect: true, }); - if (!response.ok) return null; return { context: (await response.json()) as ZeroContext, - desktopAuth, + desktopAuth: isDesktop ? (await getDesktopAccessToken()) || undefined : undefined, }; } @@ -106,7 +94,7 @@ function ZeroAuthSync({ isDesktop }: { isDesktop: boolean }) { } if (isDesktop) { - const newToken = await getDesktopAccessToken(); + const newToken = await getDesktopAccessToken({ forceRefresh: true }); if (!newToken) { handleUnauthorized(); return; @@ -262,7 +250,7 @@ export function ZeroProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isDesktop = typeof window !== "undefined" && !!window.electronAPI; - if (!isDesktop && isPublicRoute(pathname)) { + if (isPublicRoute(pathname)) { return <>{children}; } diff --git a/surfsense_web/components/tool-ui/sandbox-execute.tsx b/surfsense_web/components/tool-ui/sandbox-execute.tsx index 535968908..2bac3e1ca 100644 --- a/surfsense_web/components/tool-ui/sandbox-execute.tsx +++ b/surfsense_web/components/tool-ui/sandbox-execute.tsx @@ -16,7 +16,7 @@ import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { getDesktopAccessToken } from "@/lib/auth-fetch"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import { cn } from "@/lib/utils"; @@ -157,14 +157,10 @@ function truncateCommand(command: string, maxLen = 80): string { // ============================================================================ async function downloadSandboxFile(threadId: string, filePath: string, fileName: string) { - const token = await getDesktopAccessToken(); const url = buildBackendUrl(`/api/v1/threads/${threadId}/sandbox/download`, { path: filePath, }); - const res = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - credentials: "include", - }); + const res = await authenticatedFetch(url); if (!res.ok) { throw new Error(`Download failed: ${res.statusText}`); } diff --git a/surfsense_web/hooks/use-session.ts b/surfsense_web/hooks/use-session.ts index 6bb10456f..207475eae 100644 --- a/surfsense_web/hooks/use-session.ts +++ b/surfsense_web/hooks/use-session.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { authenticatedFetch } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; type SessionState = @@ -8,15 +9,6 @@ type SessionState = | { status: "authenticated"; authenticated: true; accessExpiresAt: number | null } | { status: "unauthenticated"; authenticated: false; accessExpiresAt: null }; -async function getSessionHeaders(): Promise { - if (typeof window === "undefined" || !window.electronAPI?.getAccessToken) { - return {}; - } - - const token = await window.electronAPI.getAccessToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} - export function useSession() { const [state, setState] = useState({ status: "loading", @@ -26,9 +18,8 @@ export function useSession() { const refresh = useCallback(async () => { try { - const response = await fetch(buildBackendUrl("/auth/session"), { - credentials: "include", - headers: await getSessionHeaders(), + const response = await authenticatedFetch(buildBackendUrl("/auth/session"), { + skipAuthRedirect: true, }); if (!response.ok) { setState({ diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 5afb291ba..0cc5224e2 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,4 +1,5 @@ import type { ZodType } from "zod"; +import { getDesktopAccessToken } from "@/lib/auth-fetch"; import { buildBackendUrl } from "@/lib/env-config"; import { getClientPlatform } from "../agent-filesystem"; import { handleUnauthorized, refreshSession } from "../auth-utils"; @@ -59,11 +60,6 @@ class BaseApiService { return typeof window !== "undefined" && !!window.electronAPI; } - private async getDesktopAccessToken(): Promise { - if (!this.isDesktopClient) return ""; - return (await window.electronAPI?.getAccessToken?.()) || ""; - } - async request( url: string, responseSchema?: ZodType, @@ -90,7 +86,7 @@ class BaseApiService { this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); const desktopAccessToken = - this.isDesktopClient && !isNoAuthEndpoint ? await this.getDesktopAccessToken() : ""; + this.isDesktopClient && !isNoAuthEndpoint ? (await getDesktopAccessToken()) || "" : ""; const defaultOptions: RequestOptions = { headers: { ...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}), @@ -174,7 +170,9 @@ class BaseApiService { } else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) { const refreshed = await refreshSession(); if (refreshed) { - const newToken = this.isDesktopClient ? await this.getDesktopAccessToken() : ""; + const newToken = this.isDesktopClient + ? (await getDesktopAccessToken({ forceRefresh: true })) || "" + : ""; return this.request(url, responseSchema, { ...mergedOptions, headers: { diff --git a/surfsense_web/lib/auth-fetch.ts b/surfsense_web/lib/auth-fetch.ts index 20b236854..a79777825 100644 --- a/surfsense_web/lib/auth-fetch.ts +++ b/surfsense_web/lib/auth-fetch.ts @@ -3,6 +3,16 @@ import { handleUnauthorized, isDesktopClient, refreshSession } from "@/lib/auth- let desktopAccessToken: string | null = null; let didSubscribeToDesktopAuth = false; +type DesktopAccessTokenOptions = { + forceRefresh?: boolean; +}; + +type AuthenticatedFetchOptions = RequestInit & { + skipAuthRedirect?: boolean; + skipRefresh?: boolean; + forceDesktopTokenRefresh?: boolean; +}; + function subscribeToDesktopAuth(): void { if (didSubscribeToDesktopAuth || typeof window === "undefined" || !window.electronAPI) { return; @@ -17,10 +27,12 @@ function subscribeToDesktopAuth(): void { }); } -export async function getDesktopAccessToken(): Promise { +export async function getDesktopAccessToken( + options: DesktopAccessTokenOptions = {} +): Promise { if (!isDesktopClient()) return null; subscribeToDesktopAuth(); - if (desktopAccessToken) return desktopAccessToken; + if (desktopAccessToken && !options.forceRefresh) return desktopAccessToken; const token = (await window.electronAPI?.getAccessToken?.()) || null; desktopAccessToken = token; return token; @@ -34,42 +46,61 @@ export function getAuthHeaders(additionalHeaders?: Record): Reco }; } +async function fetchWithAuth( + url: string, + options: RequestInit, + { forceDesktopTokenRefresh = false }: { forceDesktopTokenRefresh?: boolean } = {} +): Promise { + const headers = new Headers(options.headers); + const token = await getDesktopAccessToken({ forceRefresh: forceDesktopTokenRefresh }); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + return fetch(url, { + ...options, + headers, + credentials: options.credentials ?? "include", + }); +} + export async function authenticatedFetch( url: string, - options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } + options: AuthenticatedFetchOptions = {} ): Promise { - const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; - const token = await getDesktopAccessToken(); - const headers = { - ...(fetchOptions.headers as Record), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; + const { + skipAuthRedirect = false, + skipRefresh = false, + forceDesktopTokenRefresh = false, + ...fetchOptions + } = options; - const response = await fetch(url, { - ...fetchOptions, - headers, - credentials: "include", + const response = await fetchWithAuth(url, fetchOptions, { + forceDesktopTokenRefresh, }); - if (response.status === 401 && !skipAuthRedirect) { - if (!skipRefresh) { - const refreshed = await refreshSession(); - if (refreshed) { - const newToken = await getDesktopAccessToken(); - return fetch(url, { - ...fetchOptions, - headers: { - ...(fetchOptions.headers as Record), - ...(newToken ? { Authorization: `Bearer ${newToken}` } : {}), - }, - credentials: "include", - }); - } - } + if (response.status !== 401) { + return response; + } + let unauthorizedResponse = response; + if (!skipRefresh) { + const refreshed = await refreshSession(); + if (refreshed) { + const retryResponse = await fetchWithAuth(url, fetchOptions, { + forceDesktopTokenRefresh: true, + }); + if (retryResponse.status !== 401) { + return retryResponse; + } + unauthorizedResponse = retryResponse; + } + } + + if (!skipAuthRedirect) { handleUnauthorized(); throw new Error("Unauthorized: Redirecting to login page"); } - return response; + return unauthorizedResponse; } diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 47b2f043f..7372c8b66 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -188,9 +188,23 @@ async function doRefreshSession(): Promise { } } +let refreshPromise: Promise | null = null; + export async function refreshSession(): Promise { - if (typeof navigator !== "undefined" && "locks" in navigator) { - return navigator.locks.request("ss-token-refresh", () => doRefreshSession()); + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + if (typeof navigator !== "undefined" && "locks" in navigator) { + return navigator.locks.request("ss-token-refresh", () => doRefreshSession()); + } + return doRefreshSession(); + })(); + + try { + return await refreshPromise; + } finally { + refreshPromise = null; } - return doRefreshSession(); }