mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 08:13:09 +00:00
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:
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
139
src/utils/telegramInitData.test.ts
Normal file
139
src/utils/telegramInitData.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
63
src/utils/telegramInitData.ts
Normal file
63
src/utils/telegramInitData.ts
Normal 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
6
src/vite-env.d.ts
vendored
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user