diff --git a/surfsense_web/contracts/types/auth.types.ts b/surfsense_web/contracts/types/auth.types.ts index b630c461b..5924a0cb2 100644 --- a/surfsense_web/contracts/types/auth.types.ts +++ b/surfsense_web/contracts/types/auth.types.ts @@ -7,8 +7,8 @@ export const loginRequest = z.object({ }); export const loginResponse = z.object({ - access_token: z.string(), - token_type: z.string(), + authenticated: z.boolean(), + access_expires_at: z.number(), }); export const registerRequest = loginRequest.omit({ grant_type: true, username: true }).extend({ diff --git a/surfsense_web/hooks/use-session.ts b/surfsense_web/hooks/use-session.ts index f29661dd5..c37c0d603 100644 --- a/surfsense_web/hooks/use-session.ts +++ b/surfsense_web/hooks/use-session.ts @@ -5,7 +5,7 @@ import { buildBackendUrl } from "@/lib/env-config"; type SessionState = | { status: "loading"; authenticated: false; accessExpiresAt: null } - | { status: "authenticated"; authenticated: true; accessExpiresAt: number } + | { status: "authenticated"; authenticated: true; accessExpiresAt: number | null } | { status: "unauthenticated"; authenticated: false; accessExpiresAt: null }; export function useSession() { @@ -30,7 +30,7 @@ export function useSession() { } const data = (await response.json()) as { authenticated: boolean; - access_expires_at: number; + access_expires_at: number | null; }; setState({ status: "authenticated", diff --git a/surfsense_web/lib/apis/base-api.service.ts b/surfsense_web/lib/apis/base-api.service.ts index 66710d501..4c1b00bee 100644 --- a/surfsense_web/lib/apis/base-api.service.ts +++ b/surfsense_web/lib/apis/base-api.service.ts @@ -1,7 +1,7 @@ import type { ZodType } from "zod"; import { buildBackendUrl } from "@/lib/env-config"; import { getClientPlatform } from "../agent-filesystem"; -import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils"; +import { handleUnauthorized, refreshSession } from "../auth-utils"; import { AbortedError, AppError, @@ -53,27 +53,19 @@ class BaseApiService { 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/"]; - // Use a getter to always read fresh token from localStorage - // This ensures the token is always up-to-date after login/logout - get bearerToken(): string { - 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; - // No-op: token is now always read fresh from localStorage via the getter + private async getDesktopAccessToken(): Promise { + if (!this.isDesktopClient) return ""; + return (await window.electronAPI?.getAccessToken?.()) || ""; } async request( @@ -97,11 +89,15 @@ class BaseApiService { * REQUEST * ---------- */ + const isNoAuthEndpoint = + this.noAuthEndpoints.includes(url) || + this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || + /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); + const desktopAccessToken = + this.isDesktopClient && !isNoAuthEndpoint ? await this.getDesktopAccessToken() : ""; const defaultOptions: RequestOptions = { headers: { - ...(this.isDesktopClient && this.bearerToken - ? { Authorization: `Bearer ${this.bearerToken}` } - : {}), + ...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}), "X-SurfSense-Client-Platform": typeof window === "undefined" ? "web" : getClientPlatform(), }, @@ -118,13 +114,8 @@ class BaseApiService { }, }; - // Validate the bearer token - const isNoAuthEndpoint = - this.noAuthEndpoints.includes(url) || - this.noAuthPrefixes.some((prefix) => url.startsWith(prefix)) || - /^\/api\/v1\/invites\/[^/]+\/info$/.test(url); const refreshRetryKey = getRefreshRetryKey(mergedOptions.method, url); - if (this.isDesktopClient && !this.bearerToken && !isNoAuthEndpoint) { + if (this.isDesktopClient && !desktopAccessToken && !isNoAuthEndpoint) { throw new AuthenticationError("You are not authenticated. Please login again."); } @@ -185,8 +176,9 @@ class BaseApiService { if (options?._isRetry) { blockRefreshRetry(refreshRetryKey); } else if (!isNoAuthEndpoint && !isRefreshRetryBlocked(refreshRetryKey)) { - const newToken = await refreshAccessToken(); - if (newToken) { + const refreshed = await refreshSession(); + if (refreshed) { + const newToken = this.isDesktopClient ? await this.getDesktopAccessToken() : ""; return this.request(url, responseSchema, { ...mergedOptions, headers: { @@ -417,9 +409,6 @@ class BaseApiService { ...options, headers: { // Don't set Content-Type - let browser set it with multipart boundary - ...(this.isDesktopClient && this.bearerToken - ? { Authorization: `Bearer ${this.bearerToken}` } - : {}), ...headersWithoutContentType, }, responseType: ResponseType.JSON, diff --git a/surfsense_web/lib/auth-fetch.ts b/surfsense_web/lib/auth-fetch.ts new file mode 100644 index 000000000..2fbc0e162 --- /dev/null +++ b/surfsense_web/lib/auth-fetch.ts @@ -0,0 +1,79 @@ +import { handleUnauthorized, isDesktopClient, refreshSession } from "@/lib/auth-utils"; + +let desktopAccessToken: string | null = null; +let didSubscribeToDesktopAuth = false; + +function subscribeToDesktopAuth(): void { + if ( + didSubscribeToDesktopAuth || + typeof window === "undefined" || + !window.electronAPI + ) { + return; + } + didSubscribeToDesktopAuth = true; + + window.electronAPI.onAuthChanged?.(({ accessToken }) => { + desktopAccessToken = accessToken; + }); + void window.electronAPI.getAccessToken?.().then((token) => { + if (token) desktopAccessToken = token; + }); +} + +export async function getDesktopAccessToken(): Promise { + if (!isDesktopClient()) return null; + subscribeToDesktopAuth(); + if (desktopAccessToken) return desktopAccessToken; + const token = (await window.electronAPI?.getAccessToken?.()) || null; + desktopAccessToken = token; + return token; +} + +export function getAuthHeaders(additionalHeaders?: Record): Record { + subscribeToDesktopAuth(); + return { + ...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}), + ...additionalHeaders, + }; +} + +export async function authenticatedFetch( + url: string, + options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } +): Promise { + const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; + const token = await getDesktopAccessToken(); + const headers = { + ...(fetchOptions.headers as Record), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const response = await fetch(url, { + ...fetchOptions, + headers, + credentials: "include", + }); + + 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", + }); + } + } + + handleUnauthorized(); + throw new Error("Unauthorized: Redirecting to login page"); + } + + return response; +} diff --git a/surfsense_web/lib/auth-utils.ts b/surfsense_web/lib/auth-utils.ts index 33bdb79fd..47b2f043f 100644 --- a/surfsense_web/lib/auth-utils.ts +++ b/surfsense_web/lib/auth-utils.ts @@ -1,23 +1,20 @@ /** - * Authentication utilities for handling token expiration and redirects + * Authentication utilities for handling session expiration and redirects. */ import { buildBackendUrl } from "@/lib/env-config"; const REDIRECT_PATH_KEY = "surfsense_redirect_path"; -const BEARER_TOKEN_KEY = "surfsense_bearer_token"; -const REFRESH_TOKEN_KEY = "surfsense_refresh_token"; +const LEGACY_BEARER_TOKEN_KEY = "surfsense_bearer_token"; +const LEGACY_REFRESH_TOKEN_KEY = "surfsense_refresh_token"; -let desktopBearerToken: string | null = null; -let desktopRefreshToken: string | null = null; - -function isDesktopClient(): boolean { +export function isDesktopClient(): boolean { return typeof window !== "undefined" && !!window.electronAPI; } function purgeLegacyStoredTokens(): void { if (typeof window === "undefined") return; - localStorage.removeItem(BEARER_TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(LEGACY_BEARER_TOKEN_KEY); + localStorage.removeItem(LEGACY_REFRESH_TOKEN_KEY); } /** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */ @@ -52,24 +49,20 @@ export function getLoginPath(): string { } /** - * Clears tokens and optionally redirects to login. + * Clears auth state and optionally redirects to login. * Call this when a 401 response is received. - * Only redirects when the current route is protected; on public routes we just clear tokens. + * Only redirects when the current route is protected; on public routes we just clear state. */ export function handleUnauthorized(): void { if (typeof window === "undefined") return; const pathname = window.location.pathname; - - // Always clear tokens purgeLegacyStoredTokens(); - desktopBearerToken = null; - desktopRefreshToken = null; // Only redirect on protected routes; stay on public pages (e.g. /docs) if (!isPublicRoute(pathname)) { const currentPath = pathname + window.location.search + window.location.hash; - const excludedPaths = ["/auth", "/auth/callback", "/"]; + const excludedPaths = ["/auth", "/"]; if (!excludedPaths.includes(pathname)) { setRedirectPath(currentPath); } @@ -99,111 +92,8 @@ export function getAndClearRedirectPath(): string | null { return redirectPath; } -/** - * Gets the bearer token from localStorage - */ -export function getBearerToken(): string | null { - if (typeof window === "undefined" || !isDesktopClient()) return null; - return desktopBearerToken; -} - -/** - * Sets the bearer token in localStorage - */ -export function setBearerToken(token: string): void { - if (typeof window === "undefined") return; - purgeLegacyStoredTokens(); - desktopBearerToken = isDesktopClient() ? token : null; - syncTokensToElectron(); -} - -/** - * Clears the bearer token from localStorage - */ -export function clearBearerToken(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(BEARER_TOKEN_KEY); - desktopBearerToken = null; -} - -/** - * Gets the refresh token from localStorage - */ -export function getRefreshToken(): string | null { - if (typeof window === "undefined" || !isDesktopClient()) return null; - return desktopRefreshToken; -} - -/** - * Sets the refresh token in localStorage - */ -export function setRefreshToken(token: string): void { - if (typeof window === "undefined") return; - purgeLegacyStoredTokens(); - desktopRefreshToken = isDesktopClient() ? token : null; - syncTokensToElectron(); -} - -/** - * Clears the refresh token from localStorage - */ -export function clearRefreshToken(): void { - if (typeof window === "undefined") return; - localStorage.removeItem(REFRESH_TOKEN_KEY); - desktopRefreshToken = null; -} - -/** - * Clears all auth tokens from localStorage - */ -export function clearAllTokens(): void { - clearBearerToken(); - clearRefreshToken(); -} - -/** - * Pushes the current localStorage tokens into the Electron main process - * so that other BrowserWindows (Quick Ask, Autocomplete) can access them. - */ -function syncTokensToElectron(): void { - if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return; - const bearer = desktopBearerToken || ""; - const refresh = desktopRefreshToken || ""; - if (bearer) { - window.electronAPI.setAuthTokens(bearer, refresh); - } -} - -/** - * Attempts to pull auth tokens from the Electron main process into localStorage. - * Useful for popup windows (Quick Ask, Autocomplete) on platforms where - * localStorage is not reliably shared across BrowserWindow instances. - * Returns true if tokens were found and written to localStorage. - */ -export async function ensureTokensFromElectron(): Promise { - if (typeof window === "undefined" || !window.electronAPI?.getAuthTokens) return false; - if (getBearerToken()) return true; - - try { - if (window.electronAPI.getAccessToken) { - const token = await window.electronAPI.getAccessToken(); - if (token) { - desktopBearerToken = token; - return true; - } - } - const tokens = await window.electronAPI.getAuthTokens(); - if (tokens?.bearer) { - desktopBearerToken = tokens.bearer; - if (tokens.refresh) { - desktopRefreshToken = tokens.refresh; - } - return true; - } - } catch { - // IPC failure — fall through - } - return false; +export function getPostLoginRedirectPath(defaultPath = "/dashboard"): string { + return getAndClearRedirectPath() || defaultPath; } /** @@ -211,46 +101,45 @@ export async function ensureTokensFromElectron(): Promise { * Returns true if logout was successful (or tokens were cleared), false otherwise. */ export async function logout(): Promise { - const refreshToken = getRefreshToken(); const isDesktop = isDesktopClient(); if (isDesktop && window.electronAPI?.logout) { await window.electronAPI.logout(); - clearAllTokens(); + purgeLegacyStoredTokens(); return true; } - // Call backend to revoke the refresh token - if (refreshToken || !isDesktop) { - try { - const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", - ...(refreshToken ? { body: JSON.stringify({ refresh_token: refreshToken }) } : {}), - }); + try { + const response = await fetch(buildBackendUrl("/auth/jwt/revoke"), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); - if (!response.ok) { - console.warn("Failed to revoke refresh token:", response.status, await response.text()); - } - } catch (error) { - console.warn("Failed to revoke refresh token on server:", error); - // Continue to clear local tokens even if server call fails + if (!response.ok) { + console.warn("Failed to revoke refresh token:", response.status, await response.text()); } + } catch (error) { + console.warn("Failed to revoke refresh token on server:", error); + // Continue to clear local state even if server revoke fails. } - // Clear all tokens from localStorage - clearAllTokens(); + purgeLegacyStoredTokens(); return true; } /** - * Checks if the user is authenticated (has a token) + * Compatibility helper for legacy query gates. + * + * Web auth is cookie-backed, so the client cannot synchronously prove whether a + * session exists. Return true and let `/auth/session` or API 401s settle it. + * Desktop can synchronously check for the Electron bridge, while the access + * token itself is resolved asynchronously by auth-fetch. */ export function isAuthenticated(): boolean { - return isDesktopClient() ? !!getBearerToken() : true; + return true; } /** @@ -265,7 +154,7 @@ export function redirectToLogin(): void { const currentPath = window.location.pathname + window.location.search + window.location.hash; // Don't save auth-related paths or home page - const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"]; + const excludedPaths = ["/auth", "/", "/login", "/register", "/desktop/login"]; if (!excludedPaths.includes(window.location.pathname)) { setRedirectPath(currentPath); } @@ -273,32 +162,10 @@ export function redirectToLogin(): void { window.location.href = getLoginPath(); } -/** - * Creates headers with authorization bearer token - */ -export function getAuthHeaders(additionalHeaders?: Record): Record { - const token = getBearerToken(); - return { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...additionalHeaders, - }; -} - -/** - * Attempts to refresh the access token using the stored refresh token. - * Returns the new access token if successful, null otherwise. - */ -async function doRefreshSession(): Promise { - const currentRefreshToken = getRefreshToken(); - if (isDesktopClient() && !currentRefreshToken) { - if (window.electronAPI?.refreshAccessToken) { - const token = await window.electronAPI.refreshAccessToken(); - if (token) { - desktopBearerToken = token; - } - return token; - } - return null; +async function doRefreshSession(): Promise { + if (isDesktopClient()) { + const token = await window.electronAPI?.refreshAccessToken?.(); + return !!token; } try { @@ -308,80 +175,22 @@ async function doRefreshSession(): Promise { "Content-Type": "application/json", }, credentials: "include", - ...(currentRefreshToken ? { body: JSON.stringify({ refresh_token: currentRefreshToken }) } : {}), }); if (!response.ok) { - clearAllTokens(); - return null; + purgeLegacyStoredTokens(); + return false; } - const data = await response.json(); - if (isDesktopClient() && data.access_token) { - setBearerToken(data.access_token); - if (data.refresh_token) { - setRefreshToken(data.refresh_token); - } - } - return data.access_token ?? null; + return true; } catch { - return null; + return false; } } -export async function refreshSession(): Promise { +export async function refreshSession(): Promise { if (typeof navigator !== "undefined" && "locks" in navigator) { return navigator.locks.request("ss-token-refresh", () => doRefreshSession()); } return doRefreshSession(); } - -export async function refreshAccessToken(): Promise { - return refreshSession(); -} - -/** - * Authenticated fetch wrapper that handles 401 responses uniformly. - * On 401, attempts to refresh the token and retry the request. - * If refresh fails, redirects to login and saves the current path. - */ -export async function authenticatedFetch( - url: string, - options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean } -): Promise { - const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {}; - - const headers = getAuthHeaders(fetchOptions.headers as Record); - - const response = await fetch(url, { - ...fetchOptions, - headers, - credentials: "include", - }); - - // Handle 401 Unauthorized - if (response.status === 401 && !skipAuthRedirect) { - // Try to refresh the token (unless skipRefresh is set to prevent infinite loops) - if (!skipRefresh) { - const newToken = await refreshAccessToken(); - if (newToken) { - // Retry the original request with the new token - const retryHeaders = { - ...(fetchOptions.headers as Record), - Authorization: `Bearer ${newToken}`, - }; - return fetch(url, { - ...fetchOptions, - headers: retryHeaders, - credentials: "include", - }); - } - } - - // Refresh failed or was skipped, redirect to login - handleUnauthorized(); - throw new Error("Unauthorized: Redirecting to login page"); - } - - return response; -} diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 60fb9bd66..3359adcc9 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -141,12 +141,11 @@ interface ElectronAPI { searchSpaceId?: number | null ) => Promise; // Auth token sync across windows - getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; - setAuthTokens: (bearer: string, refresh: string) => Promise; getAccessToken: () => Promise; refreshAccessToken: () => Promise; logout: () => Promise; startGoogleOAuth: () => Promise<{ ok: true }>; + loginPassword: (email: string, password: string) => Promise<{ ok: true }>; onAuthChanged: ( callback: (payload: { authed: boolean; accessToken: string | null }) => void ) => () => void;