diff --git a/docker/proxy/Caddyfile b/docker/proxy/Caddyfile index 534a8c2c2..abc5ff199 100644 --- a/docker/proxy/Caddyfile +++ b/docker/proxy/Caddyfile @@ -31,6 +31,10 @@ flush_interval -1 } + # Zero sync auth context is a backend (FastAPI) endpoint. More specific than + # /zero/*, so Caddy's matcher-specificity sort routes it here, not to zero-cache. + reverse_proxy /zero/context backend:8000 + # Zero accepts a single path-component base URL (Zero >= 0.6). # Preserve /zero so browser cacheURL can be ${SURFSENSE_PUBLIC_URL}/zero. reverse_proxy /zero/* zero-cache:4848 diff --git a/docker/scripts/install.ps1 b/docker/scripts/install.ps1 index 23b14c3c4..761fef80e 100644 --- a/docker/scripts/install.ps1 +++ b/docker/scripts/install.ps1 @@ -330,11 +330,13 @@ Write-Info "Installation directory: $InstallDir" New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null New-Item -ItemType Directory -Path "$InstallDir\searxng" -Force | Out-Null +New-Item -ItemType Directory -Path "$InstallDir\proxy" -Force | Out-Null $Files = @( @{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" } @{ Src = "docker/docker-compose.gpu.yml"; Dest = "docker-compose.gpu.yml" } @{ Src = "docker/.env.example"; Dest = ".env.example" } + @{ Src = "docker/proxy/Caddyfile"; Dest = "proxy/Caddyfile" } @{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" } @{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" } @{ Src = "docker/searxng/settings.yml"; Dest = "searxng/settings.yml" } diff --git a/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py index fc14c8d73..1e902ea58 100644 --- a/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py +++ b/surfsense_backend/alembic/versions/168_harden_refresh_token_schema.py @@ -17,35 +17,49 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - op.add_column( - "refresh_tokens", - sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), - ) - op.add_column( - "refresh_tokens", - sa.Column("absolute_expiry", sa.TIMESTAMP(timezone=True), nullable=True), + op.execute( + "ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS " + "revoked_at TIMESTAMP WITH TIME ZONE" ) op.execute( - """ - UPDATE refresh_tokens - SET revoked_at = NOW() - WHERE is_revoked = TRUE - """ + "ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS " + "absolute_expiry TIMESTAMP WITH TIME ZONE" ) - op.alter_column( - "refresh_tokens", - "token_hash", - existing_type=sa.String(length=256), - type_=sa.String(length=64), - existing_nullable=False, + + bind = op.get_bind() + is_revoked_exists = bind.execute( + sa.text( + """ + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'refresh_tokens' + AND column_name = 'is_revoked' + ) + """ + ) + ).scalar() + + if is_revoked_exists: + op.execute( + """ + UPDATE refresh_tokens + SET revoked_at = NOW() + WHERE is_revoked = TRUE + AND revoked_at IS NULL + """ + ) + + op.execute( + "ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(64)" ) - op.drop_column("refresh_tokens", "is_revoked") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS is_revoked") def downgrade() -> None: - op.add_column( - "refresh_tokens", - sa.Column("is_revoked", sa.Boolean(), nullable=False, server_default="false"), + op.execute( + "ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS " + "is_revoked BOOLEAN NOT NULL DEFAULT false" ) op.execute( """ @@ -54,13 +68,9 @@ def downgrade() -> None: WHERE revoked_at IS NOT NULL """ ) - op.alter_column("refresh_tokens", "is_revoked", server_default=None) - op.alter_column( - "refresh_tokens", - "token_hash", - existing_type=sa.String(length=64), - type_=sa.String(length=256), - existing_nullable=False, + op.execute("ALTER TABLE refresh_tokens ALTER COLUMN is_revoked DROP DEFAULT") + op.execute( + "ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(256)" ) - op.drop_column("refresh_tokens", "absolute_expiry") - op.drop_column("refresh_tokens", "revoked_at") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS absolute_expiry") + op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS revoked_at") diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 47e529741..405718f0c 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -762,7 +762,7 @@ class Config: TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", "") # Auth - AUTH_TYPE = os.getenv("AUTH_TYPE") + AUTH_TYPE = os.getenv("AUTH_TYPE", "LOCAL") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" # Google OAuth @@ -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(); } diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 8c671029c..c60ada372 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -8,9 +8,9 @@ import packageJson from "../package.json"; -// Build-time fallback for packaged clients. Docker runtime reads plain AUTH_TYPE -// through the runtime config provider first, then falls back to this baked value. -export const BUILD_TIME_AUTH_TYPE = process.env.NEXT_PUBLIC_AUTH_TYPE || "GOOGLE"; +// Build-time fallback for packaged clients. Docker/runtime deployments default to +// local auth unless AUTH_TYPE/NEXT_PUBLIC_AUTH_TYPE explicitly selects Google. +export const BUILD_TIME_AUTH_TYPE = process.env.NEXT_PUBLIC_AUTH_TYPE || "LOCAL"; // Backend API URL. An empty string is valid in proxy mode and means // same-origin relative requests (e.g. /api/v1/... and /auth/...). diff --git a/surfsense_web/lib/runtime-auth-config.ts b/surfsense_web/lib/runtime-auth-config.ts index 9e8d1921d..d619c5957 100644 --- a/surfsense_web/lib/runtime-auth-config.ts +++ b/surfsense_web/lib/runtime-auth-config.ts @@ -4,7 +4,7 @@ export type RuntimeAuthUiMode = "GOOGLE" | "LOCAL"; export function resolveRuntimeAuthUiMode( value: string | null | undefined, - fallback: string | null | undefined = "GOOGLE" + fallback: string | null | undefined = "LOCAL" ): RuntimeAuthUiMode { const candidate = value?.trim().toUpperCase(); if (candidate === "GOOGLE") return "GOOGLE";