From c34375e579b159b9a47421c4fddd992d630e74aa Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 20 Mar 2026 22:28:39 +0300 Subject: [PATCH] fix: infinite reload loop on login when Telegram widget unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Telegram OIDC widget fails to load (ad blockers, DNS, CSP), the deep link polling fallback caused an infinite page reload: 1. pollDeepLinkToken received 202 (pending) which axios treated as success 2. loginWithDeepLink stored undefined tokens with isAuthenticated=true 3. checkAdminStatus() fired with invalid token → 401 4. Response interceptor called safeRedirectToLogin() → page reload → loop Fixes: - pollDeepLinkToken: validateStatus rejects non-200 (202 now caught in poll loop) - AUTH_ENDPOINTS: add /cabinet/auth/deeplink/ and /cabinet/auth/login/auto - safeRedirectToLogin: guard against redirect when already on /login - loginWithDeepLink: validate response tokens before storing - setTokens: validate tokens in store method (protects AutoLogin, VerifyEmail, etc.) --- src/api/auth.ts | 12 +++++++++++- src/api/client.ts | 2 ++ src/store/auth.ts | 6 ++++++ src/utils/token.ts | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 0505d7e..d35b9d6 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -291,7 +291,17 @@ export const authApi = { }, pollDeepLinkToken: async (token: string): Promise => { - const response = await apiClient.post('/cabinet/auth/deeplink/poll', { token }); + // validateStatus: only treat 200 as success. + // Server returns 202 for "pending" and 410 for "expired" — + // these must reject so the polling catch-block can handle them. + // Without this, axios resolves on 202 (it's 2xx), causing + // loginWithDeepLink to set undefined tokens + isAuthenticated=true, + // which triggers checkAdminStatus() → 401 → safeRedirectToLogin() → infinite reload. + const response = await apiClient.post( + '/cabinet/auth/deeplink/poll', + { token }, + { validateStatus: (status) => status === 200 }, + ); return response.data; }, diff --git a/src/api/client.ts b/src/api/client.ts index abe6eb8..57f9116 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -71,6 +71,8 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/oauth/', '/cabinet/auth/merge/', '/cabinet/auth/account/link/server-complete', + '/cabinet/auth/deeplink/', + '/cabinet/auth/login/auto', '/cabinet/landing/', ]; diff --git a/src/store/auth.ts b/src/store/auth.ts index 2ff82a8..81b9602 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -74,6 +74,9 @@ export const useAuthStore = create()( clearCampaignBonus: () => set({ pendingCampaignBonus: null }), setTokens: (accessToken, refreshToken) => { + if (!accessToken || !refreshToken) { + throw new Error('Invalid tokens: cannot store empty credentials'); + } tokenStorage.setTokens(accessToken, refreshToken); set({ accessToken, @@ -321,6 +324,9 @@ export const useAuthStore = create()( loginWithDeepLink: async (token) => { const response = await authApi.pollDeepLinkToken(token); + if (!response.access_token || !response.refresh_token) { + throw new Error('Invalid auth response: missing tokens'); + } tokenStorage.setTokens(response.access_token, response.refresh_token); set({ accessToken: response.access_token, diff --git a/src/utils/token.ts b/src/utils/token.ts index 11cc932..2b4b2af 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -258,6 +258,8 @@ export function getAndClearReturnUrl(): string | null { export function safeRedirectToLogin(): void { if (typeof window !== 'undefined') { + // Guard: don't redirect if already on /login to prevent infinite reload loops + if (window.location.pathname === '/login') return; saveReturnUrl(); window.location.href = '/login'; }