From f81af401f7f6ca1a28806cbf237a33da7449d893 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 19 Aug 2026 02:00:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=D1=87=D0=B8=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20initData=20=D0=B8=D0=B7=20=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=20Telegram,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=B8=D0=B7=20=D0=BA=D1=8D=D1=88=D0=B0=20?= =?UTF-8?q?SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retrieveRawInitData() ищет параметры запуска по цепочке location.href -> запись performance о навигации -> кэш tapps/launchParams в sessionStorage, записывая удачный результат обратно. Последние два источника привязаны к документу, а не к текущему запуску мини-аппы: как только SPA сменила маршрут и hash с tgWebAppData ушёл из адреса, остаются данные ПЕРВОГО запуска в этой сессии WebView. На iOS WebView переживает переоткрытия, и SDK молча отдаёт старую копию. Бэкенд принимает initData с auth_date не старше 30 суток, поэтому застрявшая копия рано или поздно пересекает порог и вход отваливается с «Invalid or expired Telegram authentication data». Спрашиваем ещё и window.Telegram.WebApp.initData (официальный мост, telegram-web-app.js из index.html) и берём вариант со свежим auth_date: какой источник протух, зависит от платформы, «самый свежий» верен всегда. Все три места чтения initData сведены в один модуль. --- src/api/client.ts | 14 ++- src/hooks/useTelegramSDK.ts | 8 +- src/main.tsx | 7 +- src/utils/telegramInitData.test.ts | 139 +++++++++++++++++++++++++++++ src/utils/telegramInitData.ts | 63 +++++++++++++ src/vite-env.d.ts | 6 ++ 6 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 src/utils/telegramInitData.test.ts create mode 100644 src/utils/telegramInitData.ts diff --git a/src/api/client.ts b/src/api/client.ts index c643618..74600b6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { retrieveRawInitData } from '@telegram-apps/sdk-react'; +import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData'; import { tokenStorage, isTokenExpired, @@ -41,13 +41,11 @@ function ensureCsrfToken(): string { const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null; - try { - const raw = retrieveRawInitData(); - if (raw) { - tokenStorage.setTelegramInitData(raw); - return raw; - } - } catch {} + const raw = readTelegramInitData(); + if (raw) { + tokenStorage.setTelegramInitData(raw); + return raw; + } return tokenStorage.getTelegramInitData(); }; diff --git a/src/hooks/useTelegramSDK.ts b/src/hooks/useTelegramSDK.ts index 2a95b87..ced149d 100644 --- a/src/hooks/useTelegramSDK.ts +++ b/src/hooks/useTelegramSDK.ts @@ -13,11 +13,11 @@ import { enableVerticalSwipes as sdkEnableVerticalSwipes, expandViewport, retrieveLaunchParams, - retrieveRawInitData, themeParamsState, closeMiniApp as sdkCloseMiniApp, postEvent, } from '@telegram-apps/sdk-react'; +import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; @@ -90,11 +90,7 @@ export function isTelegramMobile(): boolean { } export function getTelegramInitData(): string | null { - try { - return retrieveRawInitData() || null; - } catch { - return null; - } + return readTelegramInitData(); } function isDarkHexColor(hex: string): boolean { diff --git a/src/main.tsx b/src/main.tsx index d4981db..f2b7cdc 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { init, restoreInitData, - retrieveRawInitData, mountMiniApp, miniAppReady, mountViewport, @@ -21,6 +20,7 @@ import { } from '@telegram-apps/sdk-react'; import { clearStaleSessionIfNeeded } from './utils/token'; import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard'; +import { getTelegramInitData } from './utils/telegramInitData'; import { useAuthStore } from './store/auth'; import { AppWithNavigator } from './AppWithNavigator'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -38,7 +38,8 @@ installEncodingSurrogateGuard(); // Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome). // @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally. -// Without this, init() throws LaunchParamsRetrieveError on affected devices. +// Without this, init() and any launch-params retrieval below throw +// LaunchParamsRetrieveError on affected devices. // See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683 if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -62,7 +63,7 @@ if (isTelegramEnv && !alreadyInitialized) { init(); restoreInitData(); - clearStaleSessionIfNeeded(retrieveRawInitData() || null); + clearStaleSessionIfNeeded(getTelegramInitData()); // Adopt the user's Telegram client language on first run (no explicit choice yet). applyTelegramLanguage(); diff --git a/src/utils/telegramInitData.test.ts b/src/utils/telegramInitData.test.ts new file mode 100644 index 0000000..ad0074f --- /dev/null +++ b/src/utils/telegramInitData.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const retrieveRawInitData = vi.fn<() => string | undefined>(); + +vi.mock('@telegram-apps/sdk-react', () => ({ + retrieveRawInitData: () => retrieveRawInitData(), +})); + +const { getTelegramInitData } = await import('./telegramInitData'); + +/** initData ровно той формы, что приходит от Telegram. */ +function initData(authDate: number): string { + const user = encodeURIComponent(JSON.stringify({ id: 1, first_name: 'A' })); + return `user=${user}&auth_date=${authDate}&signature=s&hash=h${authDate}`; +} + +const OLD = initData(1_700_000_000); +const FRESH = initData(1_755_000_000); + +function setBridge(value: string | undefined): void { + vi.stubGlobal('window', value === undefined ? {} : { Telegram: { WebApp: { initData: value } } }); +} + +beforeEach(() => { + retrieveRawInitData.mockReset(); + setBridge(undefined); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getTelegramInitData', () => { + it('uses the Telegram bridge value', () => { + setBridge(FRESH); + retrieveRawInitData.mockReturnValue(undefined); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('falls back to the SDK when the bridge script has not loaded', () => { + setBridge(undefined); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + // Ради этого правка и делается: SDK берёт параметры запуска из записи о + // навигации и из своего кэша в sessionStorage, а оба привязаны к документу, + // а не к текущему запуску мини-аппы. На iOS WebView переживает переоткрытия, + // и SDK молча отдаёт initData прошлой недели — бэкенд её отвергает. + it('prefers the bridge when the SDK serves a stale cached launch', () => { + setBridge(FRESH); + retrieveRawInitData.mockReturnValue(OLD); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + // Обратный случай встречается на других платформах, поэтому выбираем не + // «первый доступный», а самый свежий по auth_date. + it('prefers the SDK when the bridge itself is the stale one', () => { + setBridge(OLD); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('keeps the bridge value when both are equally fresh', () => { + const bridgeCopy = `${FRESH}&tgWebAppBotInline=0`; + setBridge(bridgeCopy); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(bridgeCopy); + }); + + it('treats an empty bridge value as absent', () => { + setBridge(''); + retrieveRawInitData.mockReturnValue(FRESH); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('survives the SDK throwing outside Telegram', () => { + setBridge(FRESH); + retrieveRawInitData.mockImplementation(() => { + throw new Error('LaunchParamsRetrieveError'); + }); + + expect(getTelegramInitData()).toBe(FRESH); + }); + + it('returns null when there is no init data at all', () => { + setBridge(undefined); + retrieveRawInitData.mockImplementation(() => { + throw new Error('LaunchParamsRetrieveError'); + }); + + expect(getTelegramInitData()).toBeNull(); + }); + + it('does not crash on init data without a usable auth_date', () => { + setBridge('user=%7B%7D&hash=h'); + retrieveRawInitData.mockReturnValue('auth_date=not-a-number&hash=h'); + + expect(getTelegramInitData()).toBe('user=%7B%7D&hash=h'); + }); +}); + +// Смысл правки — единственная точка чтения initData. Прямой вызов +// retrieveRawInitData() в обход неё вернёт ту самую протухшую копию из кэша +// SDK, причём молча: ошибки не будет, вход просто перестанет работать. +describe('single source of init data', () => { + it('is read through this module only', async () => { + const { readdirSync, readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + + const srcDir = new URL('..', import.meta.url).pathname; + const offenders: string[] = []; + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!/\.tsx?$/.test(entry.name)) continue; + if (full.endsWith(join('utils', 'telegramInitData.ts'))) continue; + if (full.endsWith(join('utils', 'telegramInitData.test.ts'))) continue; + if (readFileSync(full, 'utf8').includes('retrieveRawInitData')) { + offenders.push(full.slice(srcDir.length)); + } + } + }; + walk(srcDir); + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/utils/telegramInitData.ts b/src/utils/telegramInitData.ts new file mode 100644 index 0000000..ae03a4e --- /dev/null +++ b/src/utils/telegramInitData.ts @@ -0,0 +1,63 @@ +import { retrieveRawInitData } from '@telegram-apps/sdk-react'; + +/** + * Сырая строка initData Telegram, прочитанная в момент вызова. + * + * Почему мало одного `retrieveRawInitData()` из @telegram-apps/sdk: он ищет + * параметры запуска по цепочке `location.href` → запись performance о навигации + * → собственный кэш `tapps/launchParams` в sessionStorage, записывая удачный + * результат обратно в кэш. Последние два источника привязаны не к текущему + * запуску мини-аппы, а к документу: как только SPA сменила маршрут и hash с + * `tgWebAppData` ушёл из адреса, остаются запись о навигации исходного + * документа и кэш — оба из ПЕРВОГО запуска в этой сессии WebView. На iOS такой + * WebView переживает переоткрытия мини-аппы, и SDK молча отдаёт initData + * недельной давности, без единой ошибки. + * + * Цена — отказ входа: бэкенд принимает initData с `auth_date` не старше 30 + * суток, и застрявшая копия рано или поздно пересекает порог, после чего + * логин отвечает «Invalid or expired Telegram authentication data». + * + * Поэтому спрашиваем ещё и `window.Telegram.WebApp.initData` — его пишет + * официальный мост Telegram (telegram-web-app.js, подключается в index.html), + * и он не завязан на кэш SDK. Какой из источников протух, зависит от платформы + * и способа открытия, поэтому берём не «первый доступный», а тот, у которого + * `auth_date` больше. + */ +export function getTelegramInitData(): string | null { + if (typeof window === 'undefined') return null; + + const candidates = [fromTelegramBridge(), fromSdk()].filter((value): value is string => + Boolean(value), + ); + if (candidates.length === 0) return null; + + // При равном или неразобранном auth_date побеждает мост: он ближе к + // источнику, чем кэш SDK. + return candidates.reduce((best, candidate) => + authDateOf(candidate) > authDateOf(best) ? candidate : best, + ); +} + +function fromTelegramBridge(): string | null { + return window.Telegram?.WebApp?.initData || null; +} + +function fromSdk(): string | null { + try { + return retrieveRawInitData() || null; + } catch { + // Вне Telegram параметров запуска нет — это не ошибка. + return null; + } +} + +/** Метка времени выдачи initData; 0, если её не удалось разобрать. */ +function authDateOf(initData: string): number { + try { + const raw = new URLSearchParams(initData).get('auth_date'); + const parsed = Number(raw); + return raw && Number.isFinite(parsed) ? parsed : 0; + } catch { + return 0; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index a05d633..98cb105 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -21,6 +21,12 @@ interface TelegramWebAppGlobal { offEvent?: (event: string, callback: () => void) => void; /** Closes the Mini App (injected by telegram-web-app.js). */ close?: () => void; + /** + * Raw init data, snapshotted by Telegram's own bridge script at page load. + * Independent of the @telegram-apps/sdk launch-params cache, which can go + * stale — see src/utils/telegramInitData.ts. + */ + initData?: string; } /** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */