Merge pull request #200 from BEDOLAGA-DEV/fix/telegram-miniapp-session-persistence

fix: persist refresh token across Telegram Mini App reopens
This commit is contained in:
Egor
2026-02-08 16:08:20 +03:00
committed by GitHub
3 changed files with 29 additions and 19 deletions

View File

@@ -7,7 +7,6 @@ import {
retrieveRawInitData, retrieveRawInitData,
mountMiniApp, mountMiniApp,
miniAppReady, miniAppReady,
mountThemeParams,
mountViewport, mountViewport,
expandViewport, expandViewport,
mountSwipeBehavior, mountSwipeBehavior,
@@ -43,16 +42,17 @@ if (!alreadyInitialized) {
clearStaleSessionIfNeeded(retrieveRawInitData() || null); clearStaleSessionIfNeeded(retrieveRawInitData() || null);
// Mount components — each in its own try/catch so one failure doesn't block others // 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 { try {
mountMiniApp(); mountMiniApp();
} catch { } catch {
/* already mounted */ /* already mounted */
} }
try { try {
mountThemeParams();
bindThemeParamsCssVars(); bindThemeParamsCssVars();
} catch { } catch {
/* already mounted */ /* theme params not yet available */
} }
try { try {
mountSwipeBehavior(); mountSwipeBehavior();

View File

@@ -141,12 +141,14 @@ export const useAuthStore = create<AuthState>()(
const accessToken = tokenStorage.getAccessToken(); const accessToken = tokenStorage.getAccessToken();
const refreshToken = tokenStorage.getRefreshToken(); const refreshToken = tokenStorage.getRefreshToken();
if (!accessToken || !refreshToken) { if (!refreshToken) {
set({ isLoading: false, isAuthenticated: false }); set({ isLoading: false, isAuthenticated: false });
return; 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)) { if (!isTokenValid(accessToken)) {
// Используем централизованный менеджер для refresh // Используем централизованный менеджер для refresh
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();

View File

@@ -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 = { export const tokenStorage = {
getAccessToken(): string | null { getAccessToken(): string | null {
@@ -74,7 +74,8 @@ export const tokenStorage = {
getRefreshToken(): string | null { getRefreshToken(): string | null {
try { 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 { } catch {
return null; return null;
} }
@@ -83,9 +84,12 @@ export const tokenStorage = {
setTokens(accessToken: string, refreshToken: string): void { setTokens(accessToken: string, refreshToken: string): void {
try { try {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); 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 { } 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 { migrateFromLocalStorage(): void {
try { try {
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS); 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)) { if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); 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.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 { } catch {
// ignore // ignore
} }
@@ -159,9 +165,11 @@ export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT); const stored = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
if (stored && stored !== freshInitData) { if (stored && stored !== freshInitData) {
// New Telegram session (different user) — clear all auth tokens
sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER); sessionStorage.removeItem(TOKEN_KEYS.USER);
localStorage.removeItem(TOKEN_KEYS.REFRESH);
} }
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData); sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);