mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
Merge pull request #784 from CREDO23/sur-137-bug-oauth-tokens-expire-too-quickly-connectors-and-login
[Fixes] Implement refresh token auth, connector token pre-validation, and logout improvements
This commit is contained in:
commit
459ffd2b78
22 changed files with 770 additions and 28 deletions
|
|
@ -3,7 +3,7 @@
|
|||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
||||
interface TokenHandlerProps {
|
||||
|
|
@ -35,8 +35,9 @@ const TokenHandler = ({
|
|||
// Only run on client-side
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Get token from URL parameters
|
||||
// Get tokens from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
const refreshToken = searchParams.get("refresh_token");
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
|
|
@ -50,10 +51,15 @@ const TokenHandler = ({
|
|||
// Clear the flag for future logins
|
||||
sessionStorage.removeItem("login_success_tracked");
|
||||
|
||||
// Store token in localStorage using both methods for compatibility
|
||||
// Store access token in localStorage using both methods for compatibility
|
||||
localStorage.setItem(storageKey, token);
|
||||
setBearerToken(token);
|
||||
|
||||
// Store refresh token if provided
|
||||
if (refreshToken) {
|
||||
setRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
// Check if there's a saved redirect path from before the auth flow
|
||||
const savedRedirectPath = getAndClearRedirectPath();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { BadgeCheck, LogOut } from "lucide-react";
|
||||
import { BadgeCheck, Loader2, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
|
||||
|
|
@ -26,8 +28,11 @@ export function UserDropdown({
|
|||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut) return;
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
// Track logout event and reset PostHog identity
|
||||
trackLogout();
|
||||
|
|
@ -41,15 +46,17 @@ export function UserDropdown({
|
|||
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
||||
}
|
||||
|
||||
// Revoke refresh token on server and clear all tokens from localStorage
|
||||
await logout();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
// Optionally, provide user feedback
|
||||
// Even if there's an error, try to clear tokens and redirect
|
||||
await logout();
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
|
@ -85,9 +92,17 @@ export function UserDropdown({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-xs md:text-sm">
|
||||
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Log out
|
||||
<DropdownMenuItem
|
||||
onClick={handleLogout}
|
||||
className="text-xs md:text-sm"
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{isLoggingOut ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
)}
|
||||
{isLoggingOut ? "Logging out..." : "Log out"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
|||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
@ -474,12 +475,15 @@ export function LayoutDataProvider({
|
|||
console.warn("[Logout] Electric cleanup failed (will be handled on next login):", err);
|
||||
}
|
||||
|
||||
// Revoke refresh token on server and clear all tokens from localStorage
|
||||
await logout();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
await logout();
|
||||
router.push("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Check, ChevronUp, Languages, Laptop, Loader2, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -124,6 +125,7 @@ export function SidebarUserProfile({
|
|||
}: SidebarUserProfileProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const { locale, setLocale } = useLocaleContext();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const bgColor = stringToColor(user.email);
|
||||
const initials = getInitials(user.email);
|
||||
const displayName = user.name || user.email.split("@")[0];
|
||||
|
|
@ -136,6 +138,16 @@ export function SidebarUserProfile({
|
|||
setTheme?.(newTheme);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (isLoggingOut || !onLogout) return;
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await onLogout();
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Collapsed view - just show avatar with dropdown
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
|
|
@ -242,9 +254,13 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("logout")}
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -360,9 +376,13 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("logout")}
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ZodType } from "zod";
|
||||
import { getBearerToken, handleUnauthorized } from "../auth-utils";
|
||||
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
|
||||
import { AppError, AuthenticationError, AuthorizationError, NotFoundError } from "../error";
|
||||
|
||||
enum ResponseType {
|
||||
|
|
@ -17,6 +17,7 @@ export type RequestOptions = {
|
|||
signal?: AbortSignal;
|
||||
body?: any;
|
||||
responseType?: ResponseType;
|
||||
_isRetry?: boolean; // Internal flag to prevent infinite retry loops
|
||||
// Add more options as needed
|
||||
};
|
||||
|
||||
|
|
@ -135,8 +136,23 @@ class BaseApiService {
|
|||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||
}
|
||||
|
||||
// Handle 401 first before other error handling - ensures token is cleared and user redirected
|
||||
// Handle 401 - try to refresh token first (only once)
|
||||
if (response.status === 401) {
|
||||
if (!options?._isRetry) {
|
||||
const newToken = await refreshAccessToken();
|
||||
if (newToken) {
|
||||
// Retry the request with the new token
|
||||
return this.request(url, responseSchema, {
|
||||
...mergedOptions,
|
||||
headers: {
|
||||
...mergedOptions.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
},
|
||||
_isRetry: true,
|
||||
} as RequestOptions & { responseType?: R });
|
||||
}
|
||||
}
|
||||
// Refresh failed or retry failed, redirect to login
|
||||
handleUnauthorized();
|
||||
throw new AuthenticationError(
|
||||
typeof data === "object" && "detail" in data
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
|
||||
const REDIRECT_PATH_KEY = "surfsense_redirect_path";
|
||||
const BEARER_TOKEN_KEY = "surfsense_bearer_token";
|
||||
const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
||||
|
||||
// Flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
/**
|
||||
* Saves the current path and redirects to login page
|
||||
|
|
@ -21,8 +26,9 @@ export function handleUnauthorized(): void {
|
|||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
|
||||
// Clear the token
|
||||
// Clear both tokens
|
||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
// Redirect to home page (which has login options)
|
||||
window.location.href = "/login";
|
||||
|
|
@ -66,6 +72,71 @@ export function clearBearerToken(): void {
|
|||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the refresh token from localStorage
|
||||
*/
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token in localStorage
|
||||
*/
|
||||
export function setRefreshToken(token: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the refresh token from localStorage
|
||||
*/
|
||||
export function clearRefreshToken(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all auth tokens from localStorage
|
||||
*/
|
||||
export function clearAllTokens(): void {
|
||||
clearBearerToken();
|
||||
clearRefreshToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the current user by revoking the refresh token and clearing localStorage.
|
||||
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
||||
*/
|
||||
export async function logout(): Promise<boolean> {
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
// Call backend to revoke the refresh token
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const response = await fetch(`${backendUrl}/auth/jwt/revoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all tokens from localStorage
|
||||
clearAllTokens();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authenticated (has a token)
|
||||
*/
|
||||
|
|
@ -106,14 +177,67 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
|
|||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper that handles 401 responses uniformly
|
||||
* Automatically redirects to login on 401 and saves the current path
|
||||
* Attempts to refresh the access token using the stored refresh token.
|
||||
* Returns the new access token if successful, null otherwise.
|
||||
* Exported for use by API services.
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<string | null> {
|
||||
// If already refreshing, wait for that request to complete
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
const currentRefreshToken = getRefreshToken();
|
||||
if (!currentRefreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const response = await fetch(`${backendUrl}/auth/jwt/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: currentRefreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Refresh failed, clear tokens
|
||||
clearAllTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.access_token && data.refresh_token) {
|
||||
setBearerToken(data.access_token);
|
||||
setRefreshToken(data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
options?: RequestInit & { skipAuthRedirect?: boolean; skipRefresh?: boolean }
|
||||
): Promise<Response> {
|
||||
const { skipAuthRedirect = false, ...fetchOptions } = options || {};
|
||||
const { skipAuthRedirect = false, skipRefresh = false, ...fetchOptions } = options || {};
|
||||
|
||||
const headers = getAuthHeaders(fetchOptions.headers as Record<string, string>);
|
||||
|
||||
|
|
@ -124,6 +248,23 @@ export async function authenticatedFetch(
|
|||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed or was skipped, redirect to login
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized: Redirecting to login page");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -700,6 +700,7 @@
|
|||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"logout": "Logout",
|
||||
"loggingOut": "Logging out...",
|
||||
"inbox": "Inbox",
|
||||
"search_inbox": "Search inbox",
|
||||
"mark_all_read": "Mark all as read",
|
||||
|
|
|
|||
|
|
@ -685,6 +685,7 @@
|
|||
"dark": "深色",
|
||||
"system": "系统",
|
||||
"logout": "退出登录",
|
||||
"loggingOut": "正在退出...",
|
||||
"inbox": "收件箱",
|
||||
"search_inbox": "搜索收件箱",
|
||||
"mark_all_read": "全部标记为已读",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue