diff --git a/web/src/connect.ts b/web/src/connect.ts index 622b086b4..6932ef5e9 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -67,7 +67,7 @@ const refreshTransport = createConnectTransport({ const refreshAuthClient = createClient(AuthService, refreshTransport); -export async function refreshAccessToken(): Promise { +async function doRefreshAccessToken(): Promise { const response = await refreshAuthClient.refreshToken({}); if (!response.accessToken) { @@ -78,6 +78,14 @@ export async function refreshAccessToken(): Promise { setAccessToken(response.accessToken, expiresAt); } +// All callers go through the manager to deduplicate concurrent refresh requests. +// This prevents race conditions between useTokenRefreshOnFocus (proactive refresh +// on tab focus) and the auth interceptor (reactive refresh on 401), which could +// otherwise send duplicate requests that conflict with server-side token rotation. +export async function refreshAccessToken(): Promise { + return tokenRefreshManager.refresh(doRefreshAccessToken); +} + // ============================================================================ // Authentication Interceptor // ============================================================================ @@ -104,7 +112,7 @@ const authInterceptor: Interceptor = (next) => async (req) => { } try { - await tokenRefreshManager.refresh(refreshAccessToken); + await refreshAccessToken(); const newToken = getAccessToken(); if (!newToken) { diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index 0f05a4c53..add4deb3c 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo } from "react"; import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import usePrevious from "react-use/lib/usePrevious"; import Navigation from "@/components/Navigation"; -import { useInstance } from "@/contexts/InstanceContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; @@ -14,16 +13,15 @@ const RootLayout = () => { const [searchParams] = useSearchParams(); const sm = useMediaQuery("sm"); const currentUser = useCurrentUser(); - const { memoRelatedSetting } = useInstance(); const { removeFilter } = useMemoFilterContext(); const pathname = useMemo(() => location.pathname, [location.pathname]); const prevPathname = usePrevious(pathname); useEffect(() => { - if (!currentUser && memoRelatedSetting.disallowPublicVisibility) { + if (!currentUser) { redirectOnAuthFailure(); } - }, [currentUser, memoRelatedSetting.disallowPublicVisibility]); + }, [currentUser]); useEffect(() => { // When the route changes and there is no filter in the search params, remove all filters diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 9841c438b..a82e981e4 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -1,5 +1,4 @@ import { clearAccessToken } from "@/auth-state"; -import { getInstanceConfig } from "@/instance-config"; import { ROUTES } from "@/router/routes"; const PUBLIC_ROUTES = [ @@ -27,15 +26,10 @@ export function redirectOnAuthFailure(): void { return; } - const disallowPublicVisibility = getInstanceConfig().memoRelatedSetting.disallowPublicVisibility; - const target = disallowPublicVisibility ? ROUTES.AUTH : ROUTES.EXPLORE; - - // Only redirect if it's a private route or disallowPublicVisibility is enabled - if (disallowPublicVisibility || isPrivateRoute(currentPath)) { - // Clear access token to ensure user is fully logged out - // This prevents the issue where user appears logged in but sees only public memos - // See: https://github.com/usememos/memos/issues/5565 + // Always redirect to auth page on auth failure - the user's session expired + // and they should re-authenticate rather than being sent to explore. + if (isPrivateRoute(currentPath)) { clearAccessToken(); - window.location.replace(target); + window.location.replace(ROUTES.AUTH); } }