feat: account merge flow — merge redirect, error handling, server-complete linking

- Добавлен merge_required + merge_token redirect в OAuthCallback и LinkTelegramCallback
- Server-complete OAuth linking для Mini App (внешний браузер)
- getErrorDetail: единый экстрактор ошибок из axios response
- LinkTelegramCallback: типизированный catch с getErrorDetail
- Удалён мёртвый экспорт getAndClearOAuthState
- CSRF валидация для Telegram и OAuth linking flows
- Типы ServerCompleteResponse, LinkCallbackResponse
This commit is contained in:
Fringg
2026-03-05 05:25:50 +03:00
parent 0d99ea0069
commit 2fc0759f89
10 changed files with 120 additions and 78 deletions

View File

@@ -7,6 +7,7 @@ import type {
MergeResponse, MergeResponse,
OAuthProvider, OAuthProvider,
RegisterResponse, RegisterResponse,
ServerCompleteResponse,
TokenResponse, TokenResponse,
User, User,
} from '../types'; } from '../types';
@@ -260,8 +261,8 @@ export const authApi = {
code: string, code: string,
state: string, state: string,
deviceId?: string, deviceId?: string,
): Promise<LinkCallbackResponse & { provider?: string }> => { ): Promise<ServerCompleteResponse> => {
const response = await apiClient.post<LinkCallbackResponse & { provider?: string }>( const response = await apiClient.post<ServerCompleteResponse>(
'/cabinet/auth/account/link/server-complete', '/cabinet/auth/account/link/server-complete',
{ {
code, code,

View File

@@ -81,7 +81,7 @@ const AUTH_ENDPOINTS = [
function isAuthEndpoint(url: string | undefined): boolean { function isAuthEndpoint(url: string | undefined): boolean {
if (!url) return false; if (!url) return false;
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint)); return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
} }
// Request interceptor - add auth token with expiration check // Request interceptor - add auth token with expiration check
@@ -214,15 +214,10 @@ apiClient.interceptors.response.use(
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
const authEndpoints = [
'/cabinet/auth/email/login',
'/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget',
];
const requestUrl = originalRequest.url || ''; const requestUrl = originalRequest.url || '';
const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint)); const isLoginEndpoint = isAuthEndpoint(requestUrl);
if (isAuthEndpoint) { if (isLoginEndpoint) {
// Пробрасываем ошибку в компонент для показа сообщения пользователю // Пробрасываем ошибку в компонент для показа сообщения пользователю
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -3619,6 +3619,7 @@
"returnToTelegram": "Return to Telegram to continue", "returnToTelegram": "Return to Telegram to continue",
"openTelegram": "Open Telegram", "openTelegram": "Open Telegram",
"continueInBrowser": "Continue authorization in the opened browser", "continueInBrowser": "Continue authorization in the opened browser",
"pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.",
"providers": { "providers": {
"telegram": "Telegram", "telegram": "Telegram",
"email": "Email", "email": "Email",

View File

@@ -3055,6 +3055,7 @@
"returnToTelegram": "برای ادامه به تلگرام بازگردید", "returnToTelegram": "برای ادامه به تلگرام بازگردید",
"openTelegram": "باز کردن تلگرام", "openTelegram": "باز کردن تلگرام",
"continueInBrowser": "ادامه احراز هویت در مرورگر باز شده", "continueInBrowser": "ادامه احراز هویت در مرورگر باز شده",
"pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کرده‌اید صفحه را بارگذاری مجدد کنید.",
"providers": { "providers": {
"telegram": "تلگرام", "telegram": "تلگرام",
"email": "ایمیل", "email": "ایمیل",

View File

@@ -4179,6 +4179,7 @@
"returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить",
"openTelegram": "Открыть Telegram", "openTelegram": "Открыть Telegram",
"continueInBrowser": "Продолжите авторизацию в открывшемся браузере", "continueInBrowser": "Продолжите авторизацию в открывшемся браузере",
"pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.",
"providers": { "providers": {
"telegram": "Telegram", "telegram": "Telegram",
"email": "Email", "email": "Email",

View File

@@ -3054,6 +3054,7 @@
"returnToTelegram": "返回 Telegram 继续操作", "returnToTelegram": "返回 Telegram 继续操作",
"openTelegram": "打开 Telegram", "openTelegram": "打开 Telegram",
"continueInBrowser": "请在打开的浏览器中继续授权", "continueInBrowser": "请在打开的浏览器中继续授权",
"pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。",
"providers": { "providers": {
"telegram": "Telegram", "telegram": "Telegram",
"email": "邮箱", "email": "邮箱",

View File

@@ -9,7 +9,7 @@ import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import ProviderIcon from '../components/ProviderIcon'; import ProviderIcon from '../components/ProviderIcon';
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './OAuthCallback'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback';
import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { getTelegramInitData } from '../hooks/useTelegramSDK';
import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform';
import type { LinkedProvider } from '../types'; import type { LinkedProvider } from '../types';
@@ -98,7 +98,7 @@ export default function ConnectedAccounts() {
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null); const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
const [linkingProvider, setLinkingProvider] = useState<string | null>(null); const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
const [waitingExternalLink, setWaitingExternalLink] = useState(false); const [waitingExternalLink, setWaitingExternalLink] = useState(false);
const linkedCountBeforePolling = useRef<number | null>(null); const pendingLinkProvider = useRef<string | null>(null);
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined); const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const inTelegram = useIsTelegram(); const inTelegram = useIsTelegram();
@@ -118,21 +118,26 @@ export default function ConnectedAccounts() {
refetchInterval: waitingExternalLink ? 5000 : false, refetchInterval: waitingExternalLink ? 5000 : false,
}); });
// Stop polling after 90 seconds // Stop polling after 90 seconds with timeout feedback
useEffect(() => { useEffect(() => {
if (!waitingExternalLink) return; if (!waitingExternalLink) return;
const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000); const timeout = setTimeout(() => {
return () => clearTimeout(timeout);
}, [waitingExternalLink]);
// Detect successful external link: stop polling and show toast when linked count increases
useEffect(() => {
if (!waitingExternalLink || !data) return;
const currentLinked = data.providers.filter((p) => p.linked).length;
if (linkedCountBeforePolling.current === null) return;
if (currentLinked > linkedCountBeforePolling.current) {
setWaitingExternalLink(false); setWaitingExternalLink(false);
linkedCountBeforePolling.current = null; pendingLinkProvider.current = null;
// Final refresh in case link succeeded during the last polling interval
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'warning', message: t('profile.accounts.pollingTimeout') });
}, 90_000);
return () => clearTimeout(timeout);
}, [waitingExternalLink, showToast, t, queryClient]);
// Detect successful external link: stop polling when the target provider becomes linked
useEffect(() => {
if (!waitingExternalLink || !data || !pendingLinkProvider.current) return;
const target = data.providers.find((p) => p.provider === pendingLinkProvider.current);
if (target?.linked) {
setWaitingExternalLink(false);
pendingLinkProvider.current = null;
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
} }
}, [data, waitingExternalLink, showToast, t]); }, [data, waitingExternalLink, showToast, t]);
@@ -169,14 +174,28 @@ export default function ConnectedAccounts() {
setLinkingProvider(provider); setLinkingProvider(provider);
try { try {
const { authorize_url, state } = await authApi.linkProviderInit(provider); const { authorize_url, state } = await authApi.linkProviderInit(provider);
if (!authorize_url || !state) {
throw new Error('Invalid response from server');
}
// Validate redirect URL — only allow HTTPS to prevent open redirect
let parsed: URL;
try {
parsed = new URL(authorize_url);
} catch {
throw new Error('Invalid OAuth redirect URL');
}
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect URL');
}
if (inTelegram) { if (inTelegram) {
// Mini App: open in external browser to avoid WebView OAuth restrictions. // Mini App: open in external browser to avoid WebView OAuth restrictions.
// The callback will use server-complete flow (auth via state token, no JWT). // The callback will use server-complete flow (auth via state token, no JWT).
platform.openLink(authorize_url); platform.openLink(authorize_url);
setLinkingProvider(null); setLinkingProvider(null);
// Snapshot current linked count before polling starts // Track which provider we're waiting to become linked
linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0; pendingLinkProvider.current = provider;
// Start polling for linked providers (external browser has no way to notify Mini App) // Start polling for linked providers (external browser has no way to notify Mini App)
setWaitingExternalLink(true); setWaitingExternalLink(true);
showToast({ showToast({
@@ -190,10 +209,10 @@ export default function ConnectedAccounts() {
sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
window.location.href = authorize_url; window.location.href = authorize_url;
} }
} catch { } catch (err: unknown) {
showToast({ showToast({
type: 'error', type: 'error',
message: t('profile.accounts.linkError'), message: getErrorDetail(err) || t('profile.accounts.linkError'),
}); });
setLinkingProvider(null); setLinkingProvider(null);
} }
@@ -213,8 +232,8 @@ export default function ConnectedAccounts() {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
} }
} catch { } catch (err: unknown) {
showToast({ type: 'error', message: t('profile.accounts.linkError') }); showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') });
} finally { } finally {
setLinkingProvider(null); setLinkingProvider(null);
} }
@@ -245,7 +264,7 @@ export default function ConnectedAccounts() {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
disabled={linkingProvider !== null} disabled={linkingProvider !== null || waitingExternalLink}
loading={linkingProvider === 'telegram'} loading={linkingProvider === 'telegram'}
onClick={() => handleLink('telegram')} onClick={() => handleLink('telegram')}
> >
@@ -262,7 +281,7 @@ export default function ConnectedAccounts() {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
disabled={linkingProvider !== null} disabled={linkingProvider !== null || waitingExternalLink}
loading={linkingProvider === provider.provider} loading={linkingProvider === provider.provider}
onClick={() => handleLink(provider.provider)} onClick={() => handleLink(provider.provider)}
> >

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts'; import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts';
import { getErrorDetail } from './OAuthCallback';
export default function LinkTelegramCallback() { export default function LinkTelegramCallback() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -69,8 +70,11 @@ export default function LinkTelegramCallback() {
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
} }
} catch { } catch (err: unknown) {
showToast({ type: 'error', message: t('profile.accounts.linkError') }); showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
} }
}; };

View File

@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import type { LinkCallbackResponse } from '../types'; import type { ServerCompleteResponse } from '../types';
// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts) // SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts)
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
@@ -18,15 +18,6 @@ export function saveOAuthState(state: string, provider: string): void {
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider); sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
} }
export function getAndClearOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
if (!state || !provider) return null;
return { state, provider };
}
/** Read link OAuth state without clearing (cleared only after successful match). */ /** Read link OAuth state without clearing (cleared only after successful match). */
function peekLinkOAuthState(): { state: string; provider: string } | null { function peekLinkOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
@@ -42,12 +33,15 @@ function clearLinkOAuthState(): void {
type CallbackMode = 'login' | 'link-browser' | 'link-server'; type CallbackMode = 'login' | 'link-browser' | 'link-server';
type ServerLinkResult = LinkCallbackResponse & { provider?: string }; export function getErrorDetail(err: unknown): string | null {
function getErrorDetail(err: unknown): string | null {
if (err && typeof err === 'object' && 'response' in err) { if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { detail?: string } } }).response; const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
if (resp?.data?.detail) return resp.data.detail; const detail = resp?.data?.detail;
if (typeof detail === 'string') return detail;
if (detail && typeof detail === 'object' && 'message' in detail) {
const msg = (detail as Record<string, unknown>).message;
if (typeof msg === 'string') return msg;
}
} }
if (err instanceof Error) return err.message; if (err instanceof Error) return err.message;
return null; return null;
@@ -59,7 +53,9 @@ export default function OAuthCallback() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [error, setError] = useState(''); const [error, setError] = useState('');
const [errorMode, setErrorMode] = useState<CallbackMode>('login'); const [errorMode, setErrorMode] = useState<CallbackMode>('login');
const [serverLinkResult, setServerLinkResult] = useState<ServerLinkResult | null>(null); const [serverLinkResult, setServerCompleteResponse] = useState<ServerCompleteResponse | null>(
null,
);
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const hasRun = useRef(false); const hasRun = useRef(false);
@@ -100,15 +96,22 @@ export default function OAuthCallback() {
provider = linkSaved.provider; provider = linkSaved.provider;
state = linkSaved.state; state = linkSaved.state;
} else { } else {
const loginSaved = getAndClearOAuthState(); // Peek at login state first; only clear if it matches URL state
if (loginSaved && loginSaved.state === urlState) { const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
if (loginState && loginProvider && loginState === urlState) {
sessionStorage.removeItem(OAUTH_STATE_KEY);
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
mode = 'login'; mode = 'login';
provider = loginSaved.provider; provider = loginProvider;
state = loginSaved.state; state = loginState;
} }
} }
const handle = async () => { const handle = async () => {
// Clear sensitive OAuth params (code, state) from URL immediately for all modes
window.history.replaceState({}, '', '/auth/oauth/callback');
if (mode === 'link-browser' && provider && state) { if (mode === 'link-browser' && provider && state) {
// Browser linking: user is authenticated, complete via JWT-protected endpoint // Browser linking: user is authenticated, complete via JWT-protected endpoint
try { try {
@@ -123,9 +126,9 @@ export default function OAuthCallback() {
} else { } else {
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
} }
} catch { } catch (err: unknown) {
setErrorMode('link-browser'); setErrorMode('link-browser');
setError(t('profile.accounts.linkError')); setError(getErrorDetail(err) || t('profile.accounts.linkError'));
} }
return; return;
} }
@@ -149,16 +152,13 @@ export default function OAuthCallback() {
// mode === 'link-server': No sessionStorage state found. // mode === 'link-server': No sessionStorage state found.
// This happens when OAuth was opened in external browser from Mini App. // This happens when OAuth was opened in external browser from Mini App.
// Complete linking via state-token-authenticated server endpoint. // Complete linking via state-token-authenticated server endpoint.
// Clear sensitive data from URL immediately.
window.history.replaceState({}, '', '/auth/oauth/callback');
try { try {
// Provider is resolved server-side from the state token in Redis. // Provider is resolved server-side from the state token in Redis.
const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
setServerLinkResult(response); setServerCompleteResponse(response);
} catch { } catch (err: unknown) {
setErrorMode('link-server'); setErrorMode('link-server');
setError(t('profile.accounts.linkError')); setError(getErrorDetail(err) || t('profile.accounts.linkError'));
} }
}; };
@@ -167,7 +167,11 @@ export default function OAuthCallback() {
// Server-complete result: show success with "Return to Telegram" link // Server-complete result: show success with "Return to Telegram" link
// (merge redirect is handled by the useEffect above) // (merge redirect is handled by the useEffect above)
if (serverLinkResult && !(serverLinkResult.merge_required && serverLinkResult.merge_token)) { if (
serverLinkResult &&
serverLinkResult.success &&
!(serverLinkResult.merge_required && serverLinkResult.merge_token)
) {
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
@@ -207,9 +211,34 @@ export default function OAuthCallback() {
if (error) { if (error) {
const isServerMode = errorMode === 'link-server'; const isServerMode = errorMode === 'link-server';
const isLinkBrowserMode = errorMode === 'link-browser';
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
const errorAction =
isServerMode && telegramLink ? (
<a
href={telegramLink}
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
>
{t('profile.accounts.openTelegram')}
</a>
) : isLinkBrowserMode ? (
<button
onClick={() => navigate('/profile/accounts', { replace: true })}
className="btn-primary w-full"
>
{t('profile.accounts.backToAccounts', 'Back to accounts')}
</button>
) : (
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
);
return ( return (
<div className="flex min-h-screen items-center justify-center px-4 py-8"> <div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" /> <div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
@@ -232,21 +261,7 @@ export default function OAuthCallback() {
</div> </div>
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2> <h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
<p className="mb-6 text-sm text-dark-400">{error}</p> <p className="mb-6 text-sm text-dark-400">{error}</p>
{isServerMode && telegramLink ? ( {errorAction}
<a
href={telegramLink}
className="btn-primary inline-block w-full rounded-lg bg-accent-500 px-6 py-3 text-center font-medium text-dark-950 no-underline transition-colors hover:bg-accent-400"
>
{t('profile.accounts.openTelegram')}
</a>
) : (
<button
onClick={() => navigate('/login', { replace: true })}
className="btn-primary w-full"
>
{t('auth.backToLogin', 'Back to login')}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -655,6 +655,10 @@ export interface LinkCallbackResponse {
merge_token: string | null; merge_token: string | null;
} }
export interface ServerCompleteResponse extends LinkCallbackResponse {
provider: string;
}
// Account Merge // Account Merge
export interface MergeSubscriptionPreview { export interface MergeSubscriptionPreview {
status: string; status: string;