fix(auth): читать initData из моста Telegram, а не только из кэша SDK

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 сведены в один модуль.
This commit is contained in:
Fringg
2026-08-19 02:00:41 +03:00
parent acbffa4e17
commit f81af401f7
6 changed files with 220 additions and 17 deletions

View File

@@ -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();
};

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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([]);
});
});

View File

@@ -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;
}
}

6
src/vite-env.d.ts vendored
View File

@@ -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 */