diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 4f6dd4d3..071a1647 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -439,7 +439,15 @@ async function initializeCompleteDatabase(): Promise { `); try { - sqlite.prepare("DELETE FROM sessions").run(); + const result = sqlite + .prepare("DELETE FROM sessions WHERE expires_at <= ?") + .run(new Date().toISOString()); + if (result.changes > 0) { + databaseLogger.info("Expired sessions cleaned up on startup", { + operation: "db_init_session_cleanup", + deletedSessions: result.changes, + }); + } } catch (e) { databaseLogger.warn("Could not clear expired sessions on startup", { operation: "db_init_session_cleanup_failed", diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index ca376020..d87ac850 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1670,6 +1670,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { is_oidc: !!user[0].isOidc, is_dual_auth: isDualAuth, totp_enabled: !!user[0].totpEnabled, + data_unlocked: authManager.isUserUnlocked(userId), }); } catch (err) { authLogger.error("Failed to get username", err); @@ -3529,9 +3530,14 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { * description: Failed to unlock data. */ router.post("/unlock-data", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; + const authReq = req as AuthenticatedRequest; + const userId = authReq.userId; const { password } = req.body; + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + if (!password) { return res.status(400).json({ error: "Password is required" }); } @@ -3539,6 +3545,19 @@ router.post("/unlock-data", authenticateJWT, async (req, res) => { try { const unlocked = await authManager.authenticateUser(userId, password); if (unlocked) { + const refreshedSession = + userId && authReq.sessionId + ? await authManager.refreshSessionToken(userId, authReq.sessionId) + : null; + + if (refreshedSession) { + res.cookie( + "jwt", + refreshedSession.token, + authManager.getSecureCookieOptions(req, refreshedSession.maxAge), + ); + } + res.json({ success: true, message: "Data unlocked successfully", @@ -3577,9 +3596,10 @@ router.get("/data-status", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { + const unlocked = authManager.isUserUnlocked(userId); res.json({ - unlocked: true, - message: "Data is unlocked", + unlocked, + message: unlocked ? "Data is unlocked" : "Data is locked", }); } catch (err) { authLogger.error("Failed to check data status", err, { diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 3a82dda7..e6464b6e 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -98,6 +98,7 @@ import { const systemCrypto = SystemCrypto.getInstance(); await systemCrypto.initializeJWTSecret(); await systemCrypto.initializeDatabaseKey(); + await systemCrypto.initializeEncryptionKey(); await systemCrypto.initializeInternalAuthToken(); await AutoSSLSetup.initialize(); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 849b6ef4..d7437caf 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -1,4 +1,5 @@ import jwt from "jsonwebtoken"; +import crypto from "crypto"; import { UserCrypto } from "./user-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; import { DataCrypto } from "./data-crypto.js"; @@ -29,10 +30,18 @@ interface JWTPayload { userId: string; sessionId?: string; pendingTOTP?: boolean; + dataKeyWrap?: WrappedDataKey; iat?: number; exp?: number; } +interface WrappedDataKey { + version: "v1"; + iv: string; + tag: string; + data: string; +} + interface AuthenticatedRequest extends Request { userId?: string; sessionId?: string; @@ -199,6 +208,108 @@ class AuthManager { } } + private getDataKeyAAD(userId: string, sessionId?: string): Buffer { + return Buffer.from(`${userId}:${sessionId || ""}`, "utf8"); + } + + private async wrapUserDataKey( + userId: string, + sessionId: string | undefined, + dataKey: Buffer, + ): Promise { + const encryptionKey = await this.systemCrypto.getEncryptionKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv); + cipher.setAAD(this.getDataKeyAAD(userId, sessionId)); + + const encrypted = Buffer.concat([cipher.update(dataKey), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + version: "v1", + iv: iv.toString("base64url"), + tag: tag.toString("base64url"), + data: encrypted.toString("base64url"), + }; + } + + private async unwrapUserDataKey( + userId: string, + sessionId: string | undefined, + wrapped: WrappedDataKey, + ): Promise { + if (wrapped.version !== "v1") { + throw new Error(`Unsupported wrapped data key version: ${wrapped.version}`); + } + + const encryptionKey = await this.systemCrypto.getEncryptionKey(); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + encryptionKey, + Buffer.from(wrapped.iv, "base64url"), + ); + decipher.setAAD(this.getDataKeyAAD(userId, sessionId)); + decipher.setAuthTag(Buffer.from(wrapped.tag, "base64url")); + + return Buffer.concat([ + decipher.update(Buffer.from(wrapped.data, "base64url")), + decipher.final(), + ]); + } + + private async addWrappedDataKey(payload: JWTPayload): Promise { + if (payload.pendingTOTP) { + return; + } + + const dataKey = this.userCrypto.getUserDataKey(payload.userId); + if (!dataKey) { + return; + } + + payload.dataKeyWrap = await this.wrapUserDataKey( + payload.userId, + payload.sessionId, + dataKey, + ); + } + + private async restoreDataKeyFromPayload( + payload: JWTPayload, + sessionExpiresAt?: string, + ): Promise { + if (!payload.dataKeyWrap || this.userCrypto.getUserDataKey(payload.userId)) { + return; + } + + const expiresAt = sessionExpiresAt + ? new Date(sessionExpiresAt).getTime() + : payload.exp + ? payload.exp * 1000 + : Date.now(); + + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + return; + } + + try { + const dataKey = await this.unwrapUserDataKey( + payload.userId, + payload.sessionId, + payload.dataKeyWrap, + ); + this.userCrypto.restoreUserDataKey(payload.userId, dataKey, expiresAt); + dataKey.fill(0); + } catch (error) { + databaseLogger.warn("Failed to restore data key from session token", { + operation: "session_data_key_restore_failed", + userId: payload.userId, + sessionId: payload.sessionId, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + async generateJWTToken( userId: string, options: { @@ -235,6 +346,7 @@ class AuthManager { if (!options.pendingTOTP && options.deviceType && options.deviceInfo) { const sessionId = nanoid(); payload.sessionId = sessionId; + await this.addWrappedDataKey(payload); const token = jwt.sign(payload, jwtSecret, { expiresIn, @@ -282,6 +394,7 @@ class AuthManager { return token; } + await this.addWrappedDataKey(payload); return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions); } @@ -328,6 +441,11 @@ class AuthManager { }); return null; } + + await this.restoreDataKeyFromPayload( + payload, + sessionRecords[0].expiresAt, + ); } catch (dbError) { databaseLogger.error( "Failed to check session in database during JWT verification", @@ -339,6 +457,8 @@ class AuthManager { ); return null; } + } else { + await this.restoreDataKeyFromPayload(payload); } return payload; } catch (error) { @@ -351,6 +471,59 @@ class AuthManager { } } + async refreshSessionToken( + userId: string, + sessionId: string, + ): Promise<{ token: string; maxAge: number } | null> { + const sessionRecords = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (sessionRecords.length === 0 || sessionRecords[0].userId !== userId) { + return null; + } + + const expiresAt = new Date(sessionRecords[0].expiresAt).getTime(); + const maxAge = expiresAt - Date.now(); + if (!Number.isFinite(maxAge) || maxAge <= 0) { + return null; + } + + const payload: JWTPayload = { userId, sessionId }; + await this.addWrappedDataKey(payload); + + const token = jwt.sign(payload, await this.systemCrypto.getJWTSecret(), { + expiresIn: Math.ceil(maxAge / 1000), + } as jwt.SignOptions); + + await db + .update(sessions) + .set({ + jwtToken: token, + lastActiveAt: new Date().toISOString(), + }) + .where(eq(sessions.id, sessionId)); + + try { + const { saveMemoryDatabaseToFile } = + await import("../database/db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + databaseLogger.error( + "Failed to save database after session token refresh", + saveError, + { + operation: "session_token_refresh_db_save_failed", + sessionId, + }, + ); + } + + return { token, maxAge }; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars invalidateJWTToken(_token: string): void { // expected - no-op, JWT tokens are stateless @@ -574,7 +747,10 @@ class AuthManager { const payload = await this.verifyJWTToken(token); if (!payload) { - return res.status(401).json({ error: "Invalid token" }); + return res + .clearCookie("jwt", this.getClearCookieOptions(req)) + .status(401) + .json({ error: "Invalid token" }); } if (payload.pendingTOTP) { @@ -598,10 +774,13 @@ class AuthManager { sessionId: payload.sessionId, userId: payload.userId, }); - return res.status(401).json({ - error: "Session not found", - code: "SESSION_NOT_FOUND", - }); + return res + .clearCookie("jwt", this.getClearCookieOptions(req)) + .status(401) + .json({ + error: "Session not found", + code: "SESSION_NOT_FOUND", + }); } const session = sessionRecords[0]; @@ -658,10 +837,13 @@ class AuthManager { ); }); - return res.status(401).json({ - error: "Session has expired", - code: "SESSION_EXPIRED", - }); + return res + .clearCookie("jwt", this.getClearCookieOptions(req)) + .status(401) + .json({ + error: "Session has expired", + code: "SESSION_EXPIRED", + }); } db.update(sessions) @@ -723,7 +905,10 @@ class AuthManager { const payload = await this.verifyJWTToken(token); if (!payload) { - return res.status(401).json({ error: "Invalid token" }); + return res + .clearCookie("jwt", this.getClearCookieOptions(req)) + .status(401) + .json({ error: "Invalid token" }); } if (payload.pendingTOTP) { diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 1d43238e..a51e4f5c 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -7,6 +7,7 @@ class SystemCrypto { private static instance: SystemCrypto; private jwtSecret: string | null = null; private databaseKey: Buffer | null = null; + private encryptionKey: Buffer | null = null; private internalAuthToken: string | null = null; private credentialSharingKey: Buffer | null = null; @@ -114,6 +115,46 @@ class SystemCrypto { return this.databaseKey!; } + async initializeEncryptionKey(): Promise { + try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const envKey = process.env.ENCRYPTION_KEY; + if (envKey && envKey.length >= 64) { + this.encryptionKey = Buffer.from(envKey, "hex"); + return; + } + + try { + const envContent = await fs.readFile(envPath, "utf8"); + const keyMatch = envContent.match(/^ENCRYPTION_KEY=(.+)$/m); + if (keyMatch && keyMatch[1] && keyMatch[1].length >= 64) { + this.encryptionKey = Buffer.from(keyMatch[1], "hex"); + process.env.ENCRYPTION_KEY = keyMatch[1]; + return; + } + } catch { + // expected - env file may not exist + } + + await this.generateAndGuideEncryptionKey(); + } catch (error) { + databaseLogger.error("Failed to initialize encryption key", error, { + operation: "encryption_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", + }); + throw new Error("Encryption key initialization failed"); + } + } + + async getEncryptionKey(): Promise { + if (!this.encryptionKey) { + await this.initializeEncryptionKey(); + } + return this.encryptionKey!; + } + async initializeInternalAuthToken(): Promise { try { const envToken = process.env.INTERNAL_AUTH_TOKEN; @@ -230,6 +271,23 @@ class SystemCrypto { }); } + private async generateAndGuideEncryptionKey(): Promise { + const newKey = crypto.randomBytes(32); + const newKeyHex = newKey.toString("hex"); + const instanceId = crypto.randomBytes(8).toString("hex"); + + this.encryptionKey = newKey; + + await this.updateEnvFile("ENCRYPTION_KEY", newKeyHex); + + databaseLogger.success("Encryption key auto-generated and saved to .env", { + operation: "encryption_key_auto_generated", + instanceId, + envVarName: "ENCRYPTION_KEY", + note: "Used to wrap session data keys - no restart required", + }); + } + private async generateAndGuideInternalAuthToken(): Promise { const newToken = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index b536bcf7..9a2a404d 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -265,6 +265,23 @@ class UserCrypto { return session.dataKey; } + restoreUserDataKey( + userId: string, + dataKey: Buffer, + expiresAt: number, + ): void { + const oldSession = this.userSessions.get(userId); + if (oldSession) { + oldSession.dataKey.fill(0); + } + + this.userSessions.set(userId, { + dataKey: Buffer.from(dataKey), + expiresAt, + lastActivity: Date.now(), + }); + } + logoutUser(userId: string): void { const session = this.userSessions.get(userId); if (session) { diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index c553bcf1..ed332600 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -20,7 +20,12 @@ import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/comm import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext"; import { Toaster } from "@/components/ui/sonner.tsx"; import { toast } from "sonner"; -import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts"; +import { + getUserInfo, + logoutUser, + isElectron, + isCurrentAuthInvalidationError, +} from "@/ui/main-axios.ts"; import { useTheme } from "@/components/theme-provider"; import { dbHealthMonitor } from "@/lib/db-health-monitor.ts"; import { useTranslation } from "react-i18next"; @@ -78,6 +83,7 @@ function AppContent({ const { theme, setTheme } = useTheme(); const [rightSidebarOpen, setRightSidebarOpen] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(400); + const isAuthenticatedRef = useRef(false); const isDarkMode = theme === "dark" || @@ -273,13 +279,18 @@ function AppContent({ } }) .catch((err) => { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { + if (isCurrentAuthInvalidationError(err)) { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); console.warn("Session expired - please log in again"); + return; + } + + if (!isAuthenticatedRef.current) { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); } }) .finally(() => { @@ -302,6 +313,7 @@ function AppContent({ useEffect(() => { onAuthStateChange?.(isAuthenticated); + isAuthenticatedRef.current = isAuthenticated; }, [isAuthenticated, onAuthStateChange]); const handleAuthSuccess = useCallback( diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 585fccb2..2b57cf37 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -17,6 +17,7 @@ import { sendMetricsHeartbeat, getGuacamoleDpi, getGuacamoleTokenFromHost, + isCurrentAuthInvalidationError, type RecentActivityItem, } from "@/ui/main-axios.ts"; import { useSidebar } from "@/components/ui/sidebar.tsx"; @@ -127,12 +128,10 @@ export function Dashboard({ setDbError(null); }) .catch((err) => { - setIsAdmin(false); - setUsername(null); - setUserId(null); - - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { + if (isCurrentAuthInvalidationError(err)) { + setIsAdmin(false); + setUsername(null); + setUserId(null); console.warn("Session expired - please log in again"); setDbError("Session expired - please log in again"); } else { diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 2a8b4b11..07f34402 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -1652,8 +1652,6 @@ const TerminalInner = forwardRef( setIsConnecting(false); shouldNotReconnectRef.current = true; - localStorage.removeItem("jwt"); - return; } diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 0b1cce7f..6c8405ab 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -8,7 +8,7 @@ import { RotateCcw, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { isElectron, logoutUser } from "@/ui/main-axios.ts"; +import { logoutUser } from "@/ui/main-axios.ts"; import { Sidebar, @@ -50,10 +50,6 @@ async function handleLogout() { try { await logoutUser(); - if (isElectron()) { - localStorage.removeItem("jwt"); - } - window.location.reload(); } catch (error) { console.error("Logout failed:", error); diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index dadf5bf3..4962f9b9 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -58,8 +58,6 @@ async function handleLogout() { await logoutUser(); if (isElectron()) { - localStorage.removeItem("jwt"); - const configuredServerUrl = ( window as Window & typeof globalThis & { diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index be4f408b..687427a0 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -182,8 +182,6 @@ function getLoggerForService(serviceName: string) { const electronSettingsCache = new Map(); if (isElectron()) { - localStorage.removeItem("jwt"); - (async () => { try { const electronAPI = (window as any).electronAPI; @@ -279,6 +277,40 @@ export function getCookie(name: string): string | undefined { } let userWasAuthenticated = false; +let latestAuthSuccessAt = 0; + +function markUserAuthenticated(): void { + userWasAuthenticated = true; + latestAuthSuccessAt = + typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +export function isCurrentAuthInvalidationError(error: unknown): boolean { + const authError = error as { + __staleAuthInvalidation?: boolean; + }; + + if (authError.__staleAuthInvalidation) { + return false; + } + + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data as + | Record + | undefined; + const errorCode = responseData?.code; + const errorMessage = responseData?.error; + + return ( + axiosError.response?.status === 401 && + (errorCode === "SESSION_EXPIRED" || + errorCode === "SESSION_NOT_FOUND" || + errorCode === "AUTH_REQUIRED" || + errorMessage === "Invalid token" || + errorMessage === "Authentication required" || + errorMessage === "Missing authentication token") + ); +} function createApiInstance( baseURL: string, @@ -456,12 +488,25 @@ function createApiInstance( errorMessage === "Missing authentication token"; if (isSessionExpired || isSessionNotFound || isInvalidToken) { + const requestStartedAt = + typeof error.config?.startTime === "number" + ? error.config.startTime + : 0; + const isStaleAuthInvalidation = + latestAuthSuccessAt > 0 && + requestStartedAt > 0 && + requestStartedAt < latestAuthSuccessAt; + + if (isStaleAuthInvalidation) { + ( + error as { __staleAuthInvalidation?: boolean } + ).__staleAuthInvalidation = true; + return Promise.reject(error); + } + const wasAuthenticated = userWasAuthenticated; - localStorage.removeItem("jwt"); - if (isElectron()) { - electronSettingsCache.delete("jwt"); const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; @@ -2785,7 +2830,7 @@ export async function loginUser( } if (response.data.success && !response.data.requires_totp) { - userWasAuthenticated = true; + markUserAuthenticated(); } return { @@ -2814,8 +2859,6 @@ export async function logoutUser(): Promise<{ clearTermixSessionStorage(); if (isElectron()) { - localStorage.removeItem("jwt"); - electronSettingsCache.delete("jwt"); const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; @@ -2835,8 +2878,6 @@ export async function logoutUser(): Promise<{ clearTermixSessionStorage(); if (isElectron()) { - localStorage.removeItem("jwt"); - electronSettingsCache.delete("jwt"); const electronAPI = ( window as unknown as { electronAPI?: { clearSessionCookies?: () => Promise }; @@ -2857,6 +2898,7 @@ export async function logoutUser(): Promise<{ export async function getUserInfo(): Promise { try { const response = await authApi.get("/users/me"); + markUserAuthenticated(); return response.data; } catch (error) { handleApiError(error, "fetch user info"); @@ -3253,7 +3295,7 @@ export async function verifyTOTPLogin( } if (response.data.success) { - userWasAuthenticated = true; + markUserAuthenticated(); } return response.data; diff --git a/src/ui/mobile/MobileApp.tsx b/src/ui/mobile/MobileApp.tsx index d267efdb..42d4d80c 100644 --- a/src/ui/mobile/MobileApp.tsx +++ b/src/ui/mobile/MobileApp.tsx @@ -14,7 +14,10 @@ import { TabProvider, useTabs, } from "@/ui/mobile/navigation/tabs/TabContext.tsx"; -import { getUserInfo } from "@/ui/main-axios.ts"; +import { + getUserInfo, + isCurrentAuthInvalidationError, +} from "@/ui/main-axios.ts"; import { Auth } from "@/ui/mobile/authentication/Auth.tsx"; import { useTranslation } from "react-i18next"; import { Toaster } from "@/components/ui/sonner.tsx"; @@ -33,6 +36,11 @@ const AppContent: FC = () => { const [username, setUsername] = useState(null); const [, setIsAdmin] = useState(false); const [authLoading, setAuthLoading] = useState(true); + const isAuthenticatedRef = React.useRef(false); + + useEffect(() => { + isAuthenticatedRef.current = isAuthenticated; + }, [isAuthenticated]); useEffect(() => { const checkAuth = () => { @@ -43,7 +51,6 @@ const AppContent: FC = () => { setIsAuthenticated(false); setIsAdmin(false); setUsername(null); - localStorage.removeItem("jwt"); } else { setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); @@ -51,15 +58,18 @@ const AppContent: FC = () => { } }) .catch((err) => { - setIsAuthenticated(false); - setIsAdmin(false); - setUsername(null); - - localStorage.removeItem("jwt"); - - const errorCode = err?.response?.data?.code; - if (errorCode === "SESSION_EXPIRED") { + if (isCurrentAuthInvalidationError(err)) { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); console.warn(t("errors.sessionExpired")); + return; + } + + if (!isAuthenticatedRef.current) { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); } }) .finally(() => setAuthLoading(false)); diff --git a/src/ui/mobile/apps/terminal/Terminal.tsx b/src/ui/mobile/apps/terminal/Terminal.tsx index 0bb8a6e5..6bb8fbb6 100644 --- a/src/ui/mobile/apps/terminal/Terminal.tsx +++ b/src/ui/mobile/apps/terminal/Terminal.tsx @@ -877,8 +877,6 @@ const TerminalInner = forwardRef( setIsConnecting(false); shouldNotReconnectRef.current = true; - localStorage.removeItem("jwt"); - return; }