diff --git a/src/utils/token.ts b/src/utils/token.ts new file mode 100644 index 0000000..d5b0309 --- /dev/null +++ b/src/utils/token.ts @@ -0,0 +1,151 @@ +/** + * Утилиты для безопасной работы с JWT токенами + */ + +const TOKEN_KEYS = { + ACCESS: 'access_token', + REFRESH: 'refresh_token', + USER: 'user', + TELEGRAM_INIT: 'telegram_init_data', +} as const + +interface JWTPayload { + exp?: number + iat?: number + sub?: string + [key: string]: unknown +} + +/** + * Декодирует JWT токен без верификации подписи + * Используется только для чтения payload на клиенте + */ +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 + } +} + +/** + * Проверяет, истёк ли срок действия токена + * @param token JWT токен + * @param bufferSeconds Буфер в секундах до истечения (по умолчанию 30 сек) + */ +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) +} + +/** + * Безопасное хранилище токенов + * Использует sessionStorage вместо localStorage для защиты от XSS + * Токены не сохраняются между сессиями браузера + */ +export const tokenStorage = { + getAccessToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEYS.ACCESS) + } catch { + return null + } + }, + + getRefreshToken(): string | null { + try { + return sessionStorage.getItem(TOKEN_KEYS.REFRESH) + } catch { + return null + } + }, + + setTokens(accessToken: string, refreshToken: string): void { + try { + sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken) + sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken) + } catch { + console.error('Failed to save tokens to sessionStorage') + } + }, + + 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 для миграции со старой версии + localStorage.removeItem(TOKEN_KEYS.ACCESS) + localStorage.removeItem(TOKEN_KEYS.REFRESH) + localStorage.removeItem(TOKEN_KEYS.USER) + } catch { + // ignore + } + }, + + /** + * Миграция токенов из localStorage в sessionStorage + * Вызывается при инициализации для обратной совместимости + */ + migrateFromLocalStorage(): void { + try { + const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS) + const refreshToken = localStorage.getItem(TOKEN_KEYS.REFRESH) + + 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) + } catch { + // ignore + } + }, + + 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 { + // ignore + } + }, +}