mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-13 16:23:08 +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 axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
|
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
|
||||||
import {
|
import {
|
||||||
tokenStorage,
|
tokenStorage,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
@@ -41,13 +41,11 @@ function ensureCsrfToken(): string {
|
|||||||
const getTelegramInitData = (): string | null => {
|
const getTelegramInitData = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
try {
|
const raw = readTelegramInitData();
|
||||||
const raw = retrieveRawInitData();
|
if (raw) {
|
||||||
if (raw) {
|
tokenStorage.setTelegramInitData(raw);
|
||||||
tokenStorage.setTelegramInitData(raw);
|
return raw;
|
||||||
return raw;
|
}
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return tokenStorage.getTelegramInitData();
|
return tokenStorage.getTelegramInitData();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import {
|
|||||||
enableVerticalSwipes as sdkEnableVerticalSwipes,
|
enableVerticalSwipes as sdkEnableVerticalSwipes,
|
||||||
expandViewport,
|
expandViewport,
|
||||||
retrieveLaunchParams,
|
retrieveLaunchParams,
|
||||||
retrieveRawInitData,
|
|
||||||
themeParamsState,
|
themeParamsState,
|
||||||
closeMiniApp as sdkCloseMiniApp,
|
closeMiniApp as sdkCloseMiniApp,
|
||||||
postEvent,
|
postEvent,
|
||||||
} from '@telegram-apps/sdk-react';
|
} from '@telegram-apps/sdk-react';
|
||||||
|
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
|
||||||
|
|
||||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||||
|
|
||||||
@@ -90,11 +90,7 @@ export function isTelegramMobile(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTelegramInitData(): string | null {
|
export function getTelegramInitData(): string | null {
|
||||||
try {
|
return readTelegramInitData();
|
||||||
return retrieveRawInitData() || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDarkHexColor(hex: string): boolean {
|
function isDarkHexColor(hex: string): boolean {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
init,
|
init,
|
||||||
restoreInitData,
|
restoreInitData,
|
||||||
retrieveRawInitData,
|
|
||||||
mountMiniApp,
|
mountMiniApp,
|
||||||
miniAppReady,
|
miniAppReady,
|
||||||
mountViewport,
|
mountViewport,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
} from '@telegram-apps/sdk-react';
|
} from '@telegram-apps/sdk-react';
|
||||||
import { clearStaleSessionIfNeeded } from './utils/token';
|
import { clearStaleSessionIfNeeded } from './utils/token';
|
||||||
import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard';
|
import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard';
|
||||||
|
import { getTelegramInitData } from './utils/telegramInitData';
|
||||||
import { useAuthStore } from './store/auth';
|
import { useAuthStore } from './store/auth';
|
||||||
import { AppWithNavigator } from './AppWithNavigator';
|
import { AppWithNavigator } from './AppWithNavigator';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
@@ -38,7 +38,8 @@ installEncodingSurrogateGuard();
|
|||||||
|
|
||||||
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
// 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.
|
// @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
|
// See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683
|
||||||
if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') {
|
if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -62,7 +63,7 @@ if (isTelegramEnv && !alreadyInitialized) {
|
|||||||
init();
|
init();
|
||||||
restoreInitData();
|
restoreInitData();
|
||||||
|
|
||||||
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
clearStaleSessionIfNeeded(getTelegramInitData());
|
||||||
|
|
||||||
// Adopt the user's Telegram client language on first run (no explicit choice yet).
|
// Adopt the user's Telegram client language on first run (no explicit choice yet).
|
||||||
applyTelegramLanguage();
|
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;
|
offEvent?: (event: string, callback: () => void) => void;
|
||||||
/** Closes the Mini App (injected by telegram-web-app.js). */
|
/** Closes the Mini App (injected by telegram-web-app.js). */
|
||||||
close?: () => void;
|
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 */
|
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */
|
||||||
|
|||||||
Reference in New Issue
Block a user