import axios from 'axios'; import { getCloudStorageItem, setCloudStorageItem, deleteCloudStorageItem, } from '@telegram-apps/sdk-react'; import { isInTelegramWebApp } from '../hooks/useTelegramSDK'; import { API } from '../config/constants'; import { reportPossibleBackendDown } from '../api/health'; const TOKEN_KEYS = { ACCESS: 'access_token', REFRESH: 'refresh_token', USER: 'user', TELEGRAM_INIT: 'telegram_init_data', } as const; // --- Telegram CloudStorage backup for the refresh token --- // The Telegram WebView can wipe localStorage between sessions. Mirroring the refresh // token to per-user CloudStorage lets initialize() recover the session instead of // dropping the user to the login screen. Best-effort: failures fall back to localStorage. const CLOUD_REFRESH_KEY = TOKEN_KEYS.REFRESH; function mirrorRefreshTokenToCloud(refreshToken: string): void { if (!isInTelegramWebApp()) return; try { void setCloudStorageItem(CLOUD_REFRESH_KEY, refreshToken).catch(() => {}); } catch {} } function removeRefreshTokenFromCloud(): void { if (!isInTelegramWebApp()) return; try { void deleteCloudStorageItem(CLOUD_REFRESH_KEY).catch(() => {}); } catch {} } export async function restoreRefreshTokenFromCloud(): Promise { if (!isInTelegramWebApp()) return null; try { const value = await getCloudStorageItem(CLOUD_REFRESH_KEY); const token = value || null; if (token) { try { localStorage.setItem(TOKEN_KEYS.REFRESH, token); } catch {} } return token; } catch { return null; } } interface JWTPayload { exp?: number; iat?: number; sub?: string; [key: string]: unknown; } export function decodeJWT(token: string): JWTPayload | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = parts[1]; const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decoded); } catch { return null; } } export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean { if (!token) return true; const payload = decodeJWT(token); if (!payload?.exp) return true; const now = Math.floor(Date.now() / 1000); return payload.exp <= now + bufferSeconds; } export function isTokenValid(token: string | null): boolean { if (!token) return false; return !isTokenExpired(token); } export const tokenStorage = { getAccessToken(): string | null { try { return sessionStorage.getItem(TOKEN_KEYS.ACCESS); } catch { return null; } }, getRefreshToken(): string | null { try { return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH); } catch { return null; } }, setTokens(accessToken: string, refreshToken: string): void { try { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); sessionStorage.removeItem(TOKEN_KEYS.REFRESH); mirrorRefreshTokenToCloud(refreshToken); } catch {} }, setAccessToken(accessToken: string): void { try { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); } catch { console.error('Failed to save access token to sessionStorage'); } }, clearTokens(): void { try { sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.USER); localStorage.removeItem(TOKEN_KEYS.ACCESS); localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem(TOKEN_KEYS.USER); removeRefreshTokenFromCloud(); } catch {} }, migrateFromLocalStorage(): void { try { const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS); if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) { sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); } localStorage.removeItem(TOKEN_KEYS.ACCESS); 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 {} }, getTelegramInitData(): string | null { try { return sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT); } catch { return null; } }, setTelegramInitData(data: string): void { try { sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data); } catch {} }, }; function extractTelegramUserId(initData: string): string | null { try { const params = new URLSearchParams(initData); const userJson = params.get('user'); if (!userJson) return null; const user = JSON.parse(userJson); return user.id != null ? String(user.id) : null; } catch { return null; } } const TG_USER_ID_KEY = 'tg_user_id'; export function clearStaleSessionIfNeeded(freshInitData: string | null): void { if (!freshInitData) return; try { const currentTgUserId = extractTelegramUserId(freshInitData); const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY); if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) { sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.USER); localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem('cabinet-auth'); } if (currentTgUserId) { localStorage.setItem(TG_USER_ID_KEY, currentTgUserId); } sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData); localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT); } catch {} } class TokenRefreshManager { private isRefreshing = false; private refreshPromise: Promise | null = null; private subscribers: ((token: string | null) => void)[] = []; private refreshEndpoint = '/api/cabinet/auth/refresh'; /** * True when the most recent refresh failed at the TRANSPORT level (backend * unreachable / timeout) rather than because the refresh token was rejected. * Callers read this right after a null `refreshAccessToken()` to decide whether * to destroy the session (rejected token) or KEEP it so the recoverable * ServiceUnavailableScreen can resume once the backend returns. Synchronously * accurate: it is set inside doRefresh before the shared promise resolves, so * every awaiter (including deduped concurrent callers) reads a consistent value. */ lastFailureWasTransport = false; setRefreshEndpoint(endpoint: string): void { this.refreshEndpoint = endpoint; } async refreshAccessToken(): Promise { if (this.isRefreshing && this.refreshPromise) { return this.refreshPromise; } this.lastFailureWasTransport = false; const refreshToken = tokenStorage.getRefreshToken(); if (!refreshToken) { return null; } this.isRefreshing = true; this.refreshPromise = this.doRefresh(refreshToken); try { const result = await this.refreshPromise; this.notifySubscribers(result); return result; } finally { this.isRefreshing = false; this.refreshPromise = null; } } // Uses plain axios (not apiClient) to avoid circular dependency private async doRefresh(refreshToken: string): Promise { try { const response = await axios.post<{ access_token?: string }>( this.refreshEndpoint, { refresh_token: refreshToken }, // Without an explicit timeout this inherits axios's default of 0 (no // timeout): on the persisted-token bootstrap path an unreachable backend // would hang the refresh indefinitely, pinning the app on a blank loader. { headers: { 'Content-Type': 'application/json' }, timeout: API.TIMEOUT_MS }, ); const newAccessToken = response.data.access_token; if (newAccessToken) { tokenStorage.setAccessToken(newAccessToken); return newAccessToken; } return null; } catch (err) { // This bare-axios call bypasses the apiClient interceptor, so a dead // backend on the refresh-only bootstrap path would otherwise be swallowed // here and silently fall through to a login redirect. Distinguish a // transport-level failure (no response) from an invalid/expired token (a // 4xx response) and surface the service-unavailable screen for the former. if (axios.isAxiosError(err) && !err.response) { this.lastFailureWasTransport = true; void reportPossibleBackendDown(); } return null; } } subscribe(callback: (token: string | null) => void): () => void { this.subscribers.push(callback); return () => { this.subscribers = this.subscribers.filter((cb) => cb !== callback); }; } private notifySubscribers(token: string | null): void { this.subscribers.forEach((cb) => cb(token)); this.subscribers = []; } get isRefreshInProgress(): boolean { return this.isRefreshing; } async waitForRefresh(): Promise { if (this.refreshPromise) { return this.refreshPromise; } return tokenStorage.getAccessToken(); } } export const tokenRefreshManager = new TokenRefreshManager(); const RETURN_URL_KEY = 'auth_return_url'; export function saveReturnUrl(): void { if (typeof window !== 'undefined') { const currentPath = window.location.pathname + window.location.search; if (currentPath && currentPath !== '/login') { sessionStorage.setItem(RETURN_URL_KEY, currentPath); } } } export function getAndClearReturnUrl(): string | null { if (typeof window !== 'undefined') { const url = sessionStorage.getItem(RETURN_URL_KEY); sessionStorage.removeItem(RETURN_URL_KEY); return url; } return null; } export function safeRedirectToLogin(): void { if (typeof window !== 'undefined') { // Guard: don't redirect if already on /login to prevent infinite reload loops if (window.location.pathname === '/login') return; saveReturnUrl(); window.location.href = '/login'; } } export function isValidRedirectUrl(url: string): boolean { if (!url) return false; if (url.startsWith('/') && !url.startsWith('//')) { return true; } try { const parsed = new URL(url, window.location.origin); return parsed.origin === window.location.origin; } catch { return false; } }