fix(web):centralize session auth fetch

This commit is contained in:
Anish Sarkar 2026-06-24 03:55:40 +05:30
parent 951cb1a717
commit d2a8d088c7
6 changed files with 142 additions and 266 deletions

View file

@ -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({

View file

@ -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",

View file

@ -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<string> {
if (!this.isDesktopClient) return "";
return (await window.electronAPI?.getAccessToken?.()) || "";
}
async request<T, R extends ResponseType = ResponseType.JSON>(
@ -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,

View file

@ -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<string | null> {
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<string, string>): Record<string, string> {
subscribeToDesktopAuth();
return {
...(desktopAccessToken ? { Authorization: `Bearer ${desktopAccessToken}` } : {}),
...additionalHeaders,
};
}
export async function authenticatedFetch(
url: string,
options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean }
): Promise<Response> {
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
const token = await getDesktopAccessToken();
const headers = {
...(fetchOptions.headers as Record<string, string>),
...(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<string, string>),
...(newToken ? { Authorization: `Bearer ${newToken}` } : {}),
},
credentials: "include",
});
}
}
handleUnauthorized();
throw new Error("Unauthorized: Redirecting to login page");
}
return response;
}

View file

@ -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<boolean> {
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<boolean> {
* Returns true if logout was successful (or tokens were cleared), false otherwise.
*/
export async function logout(): Promise<boolean> {
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<string, string>): Record<string, string> {
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<string | null> {
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<boolean> {
if (isDesktopClient()) {
const token = await window.electronAPI?.refreshAccessToken?.();
return !!token;
}
try {
@ -308,80 +175,22 @@ async function doRefreshSession(): Promise<string | null> {
"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<string | null> {
export async function refreshSession(): Promise<boolean> {
if (typeof navigator !== "undefined" && "locks" in navigator) {
return navigator.locks.request("ss-token-refresh", () => doRefreshSession());
}
return doRefreshSession();
}
export async function refreshAccessToken(): Promise<string | null> {
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<Response> {
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
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<string, string>),
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;
}

View file

@ -141,12 +141,11 @@ interface ElectronAPI {
searchSpaceId?: number | null
) => Promise<LocalTextFileResult>;
// Auth token sync across windows
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
getAccessToken: () => Promise<string | null>;
refreshAccessToken: () => Promise<string | null>;
logout: () => Promise<void>;
startGoogleOAuth: () => Promise<{ ok: true }>;
loginPassword: (email: string, password: string) => Promise<{ ok: true }>;
onAuthChanged: (
callback: (payload: { authed: boolean; accessToken: string | null }) => void
) => () => void;