From 20ea2006ff703a76208c8ecfb8e2d9c2d789ccc4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 16:06:51 +0300 Subject: [PATCH] fix: persist refresh token across Telegram Mini App reopens Telegram Mini App creates a new WebView on each open, clearing sessionStorage. Combined with 24h auth_date validation on backend, returning users couldn't re-authenticate after closing the app. - Move refresh token to localStorage for persistence across sessions - Keep access token in sessionStorage (short-lived, OK to lose) - Fix auth store initialize() to recover when access token is missing but refresh token exists in localStorage - Remove separate mountThemeParams() call to fix ConcurrentCallError (mountMiniApp() handles it internally in SDK v3) - Update token migration logic for new storage strategy --- src/main.tsx | 6 +++--- src/store/auth.ts | 6 ++++-- src/utils/token.ts | 36 ++++++++++++++++++++++-------------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 6d79dbd..1bc3632 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,6 @@ import { retrieveRawInitData, mountMiniApp, miniAppReady, - mountThemeParams, mountViewport, expandViewport, mountSwipeBehavior, @@ -43,16 +42,17 @@ if (!alreadyInitialized) { clearStaleSessionIfNeeded(retrieveRawInitData() || null); // Mount components — each in its own try/catch so one failure doesn't block others + // Note: mountMiniApp() internally mounts themeParams in SDK v3, + // so we don't call mountThemeParams() separately to avoid ConcurrentCallError try { mountMiniApp(); } catch { /* already mounted */ } try { - mountThemeParams(); bindThemeParamsCssVars(); } catch { - /* already mounted */ + /* theme params not yet available */ } try { mountSwipeBehavior(); diff --git a/src/store/auth.ts b/src/store/auth.ts index 56364bb..02590ef 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -141,12 +141,14 @@ export const useAuthStore = create()( const accessToken = tokenStorage.getAccessToken(); const refreshToken = tokenStorage.getRefreshToken(); - if (!accessToken || !refreshToken) { + if (!refreshToken) { set({ isLoading: false, isAuthenticated: false }); return; } - // Проверяем валидность токена перед использованием + // No access token or it's expired — try refresh + // This handles Mini App reopens where sessionStorage was cleared + // but refresh token persists in localStorage if (!isTokenValid(accessToken)) { // Используем централизованный менеджер для refresh const newToken = await tokenRefreshManager.refreshAccessToken(); diff --git a/src/utils/token.ts b/src/utils/token.ts index 9ce503a..add4724 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -60,8 +60,8 @@ export function isTokenValid(token: string | null): boolean { /** * Безопасное хранилище токенов - * Использует sessionStorage вместо localStorage для защиты от XSS - * Токены не сохраняются между сессиями браузера + * Access token: sessionStorage (short-lived, cleared on tab close) + * Refresh token: localStorage (persistent, survives Mini App reopens, server-validated) */ export const tokenStorage = { getAccessToken(): string | null { @@ -74,7 +74,8 @@ export const tokenStorage = { getRefreshToken(): string | null { try { - return sessionStorage.getItem(TOKEN_KEYS.REFRESH); + // Refresh token in localStorage for persistence across Mini App reopens + return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH); } catch { return null; } @@ -83,9 +84,12 @@ export const tokenStorage = { setTokens(accessToken: string, refreshToken: string): void { try { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); - sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); + // Refresh token in localStorage — survives Mini App tab close/reopen + localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); + // Clean up old sessionStorage refresh token (migration) + sessionStorage.removeItem(TOKEN_KEYS.REFRESH); } catch { - console.error('Failed to save tokens to sessionStorage'); + // Storage unavailable } }, @@ -112,24 +116,26 @@ export const tokenStorage = { }, /** - * Миграция токенов из localStorage в sessionStorage - * Вызывается при инициализации для обратной совместимости + * Миграция токенов для обратной совместимости. + * Access token: sessionStorage (short-lived, OK to lose on tab close) + * Refresh token: localStorage (persistent, survives Mini App reopens) */ migrateFromLocalStorage(): void { try { const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS); - const refreshToken = localStorage.getItem(TOKEN_KEYS.REFRESH); + // Migrate access token to sessionStorage if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); } - if (refreshToken && !sessionStorage.getItem(TOKEN_KEYS.REFRESH)) { - sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); - } - - // Удаляем из localStorage после миграции localStorage.removeItem(TOKEN_KEYS.ACCESS); - localStorage.removeItem(TOKEN_KEYS.REFRESH); + + // Migrate refresh token from sessionStorage to localStorage + const refreshInSession = sessionStorage.getItem(TOKEN_KEYS.REFRESH); + if (refreshInSession && !localStorage.getItem(TOKEN_KEYS.REFRESH)) { + localStorage.setItem(TOKEN_KEYS.REFRESH, refreshInSession); + } + sessionStorage.removeItem(TOKEN_KEYS.REFRESH); } catch { // ignore } @@ -159,9 +165,11 @@ export function clearStaleSessionIfNeeded(freshInitData: string | null): void { const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT); if (stored && stored !== freshInitData) { + // New Telegram session (different user) — clear all auth tokens sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.USER); + localStorage.removeItem(TOKEN_KEYS.REFRESH); } sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);