diff --git a/surfsense_web/hooks/use-session.ts b/surfsense_web/hooks/use-session.ts new file mode 100644 index 000000000..f29661dd5 --- /dev/null +++ b/surfsense_web/hooks/use-session.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { buildBackendUrl } from "@/lib/env-config"; + +type SessionState = + | { status: "loading"; authenticated: false; accessExpiresAt: null } + | { status: "authenticated"; authenticated: true; accessExpiresAt: number } + | { status: "unauthenticated"; authenticated: false; accessExpiresAt: null }; + +export function useSession() { + const [state, setState] = useState({ + status: "loading", + authenticated: false, + accessExpiresAt: null, + }); + + const refresh = useCallback(async () => { + try { + const response = await fetch(buildBackendUrl("/auth/session"), { + credentials: "include", + }); + if (!response.ok) { + setState({ + status: "unauthenticated", + authenticated: false, + accessExpiresAt: null, + }); + return; + } + const data = (await response.json()) as { + authenticated: boolean; + access_expires_at: number; + }; + setState({ + status: "authenticated", + authenticated: true, + accessExpiresAt: data.access_expires_at, + }); + } catch { + setState({ + status: "unauthenticated", + authenticated: false, + accessExpiresAt: null, + }); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { ...state, refresh }; +} diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 678293d8e..66710d501 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -19,6 +19,25 @@ enum ResponseType { // Add more response types as needed } +const REFRESH_RETRY_BLOCK_MS = 30_000; +const refreshRetryBlockedUntil = new Map(); + +function getRefreshRetryKey(method: RequestOptions["method"], url: string): string { + return `${method}:${url}`; +} + +function isRefreshRetryBlocked(key: string): boolean { + const blockedUntil = refreshRetryBlockedUntil.get(key); + if (!blockedUntil) return false; + if (Date.now() < blockedUntil) return true; + refreshRetryBlockedUntil.delete(key); + return false; +} + +function blockRefreshRetry(key: string): void { + refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS); +} + export type RequestOptions = { method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record; @@ -31,7 +50,12 @@ export type RequestOptions = { }; class BaseApiService { - noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"]; + noAuthEndpoints: string[] = [ + "/auth/jwt/login", + "/auth/register", + "/auth/refresh", + "/auth/jwt/refresh", + ]; // Prefixes that don't require auth (checked with startsWith) noAuthPrefixes: string[] = ["/api/v1/public/"]; @@ -42,6 +66,10 @@ class BaseApiService { return typeof window !== "undefined" ? getBearerToken() || "" : ""; } + get isDesktopClient(): boolean { + return typeof window !== "undefined" && !!window.electronAPI; + } + // Keep for backward compatibility, but token is now always read from localStorage setBearerToken(_bearerToken: string) { void _bearerToken; @@ -71,7 +99,9 @@ class BaseApiService { */ const defaultOptions: RequestOptions = { headers: { - Authorization: `Bearer ${this.bearerToken || ""}`, + ...(this.isDesktopClient && this.bearerToken + ? { Authorization: `Bearer ${this.bearerToken}` } + : {}), "X-SurfSense-Client-Platform": typeof window === "undefined" ? "web" : getClientPlatform(), }, @@ -93,7 +123,8 @@ class BaseApiService { this.noAuthEndpoints.includes(url) || this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); - if (!this.bearerToken && !isNoAuthEndpoint) { + const refreshRetryKey = getRefreshRetryKey(mergedOptions.method, url); + if (this.isDesktopClient && !this.bearerToken && !isNoAuthEndpoint) { throw new AuthenticationError("You are not authenticated. Please login again."); } @@ -104,6 +135,7 @@ class BaseApiService { method: mergedOptions.method, headers: mergedOptions.headers, signal: mergedOptions.signal, + credentials: "include", }; // Automatically stringify body if Content-Type is application/json and body is an object @@ -150,18 +182,21 @@ class BaseApiService { // Handle 401 - try to refresh token first (only once) if (response.status === 401) { - if (!options?._isRetry) { + if (options?._isRetry) { + blockRefreshRetry(refreshRetryKey); + } else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) { const newToken = await refreshAccessToken(); if (newToken) { return this.request(url, responseSchema, { ...mergedOptions, headers: { ...mergedOptions.headers, - Authorization: `Bearer ${newToken}`, + ...(this.isDesktopClient ? { Authorization: `Bearer ${newToken}` } : {}), }, _isRetry: true, } as RequestOptions & { responseType?: R }); } + blockRefreshRetry(refreshRetryKey); } handleUnauthorized(); throw new AuthenticationError( @@ -196,6 +231,7 @@ class BaseApiService { ); } } + refreshRetryBlockedUntil.delete(getRefreshRetryKey(mergedOptions.method, url)); // biome-ignore lint/suspicious: Unknown let data; @@ -381,7 +417,9 @@ class BaseApiService { ...options, headers: { // Don't set Content-Type - let browser set it with multipart boundary - Authorization: `Bearer ${this.bearerToken}`, + ...(this.isDesktopClient && this.bearerToken + ? { Authorization: `Bearer ${this.bearerToken}` } + : {}), ...headersWithoutContentType, }, responseType: ResponseType.JSON,