From 60f16e64e8cec2f540b2c49764fe711ddc9da86d Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 2 Mar 2026 04:10:05 +0300 Subject: [PATCH] fix: support VK ID OAuth 2.1 device_id in frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract device_id from VK callback URL query params - Pass device_id through store → API → backend - Add useRef guard to prevent useEffect double-fire - Fix URL validation catch-block anti-pattern in Login - Improve error extraction in OAuthCallback (show Error.message) --- src/api/auth.ts | 2 ++ src/pages/Login.tsx | 9 +++++---- src/pages/OAuthCallback.tsx | 20 +++++++++++++------- src/store/auth.ts | 10 ++++++++-- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 8082967..dd010bf 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -174,6 +174,7 @@ export const authApi = { provider: string, code: string, state: string, + deviceId?: string | null, campaignSlug?: string | null, referralCode?: string | null, ): Promise => { @@ -182,6 +183,7 @@ export const authApi = { { code, state, + device_id: deviceId || undefined, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, }, diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 0203b7e..e346551 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -127,14 +127,15 @@ export default function Login() { const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider); // Validate redirect URL — only allow HTTPS to prevent open redirect + let parsed: URL; try { - const parsed = new URL(authorize_url); - if (parsed.protocol !== 'https:') { - throw new Error('Invalid OAuth redirect URL'); - } + parsed = new URL(authorize_url); } catch { throw new Error('Invalid OAuth redirect URL'); } + if (parsed.protocol !== 'https:') { + throw new Error('Invalid OAuth redirect URL'); + } saveOAuthState(state, provider); window.location.href = authorize_url; diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index dfe797f..ffccf93 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/auth'; @@ -28,6 +28,7 @@ export default function OAuthCallback() { const [error, setError] = useState(''); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const hasRun = useRef(false); useEffect(() => { if (isAuthenticated) { @@ -35,9 +36,15 @@ export default function OAuthCallback() { return; } + // Prevent double-fire from React StrictMode or dependency changes + if (hasRun.current) return; + hasRun.current = true; + const authenticate = async () => { const code = searchParams.get('code'); const urlState = searchParams.get('state'); + // VK ID returns device_id in callback URL (required for token exchange) + const deviceId = searchParams.get('device_id'); if (!code || !urlState) { setError(t('auth.oauthError', 'Authorization was denied or failed')); @@ -58,14 +65,13 @@ export default function OAuthCallback() { } try { - await loginWithOAuth(saved.provider, code, urlState); + await loginWithOAuth(saved.provider, code, urlState, deviceId); navigate('/', { replace: true }); } catch (err: unknown) { - const error = err as { response?: { data?: { detail?: string } } }; - setError( - error.response?.data?.detail || - t('auth.oauthError', 'Authorization was denied or failed'), - ); + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + (err instanceof Error ? err.message : null); + setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); } }; diff --git a/src/store/auth.ts b/src/store/auth.ts index 6f7a392..f7e555f 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -38,7 +38,12 @@ interface AuthState { loginWithTelegram: (initData: string) => Promise; loginWithTelegramWidget: (data: TelegramWidgetData) => Promise; loginWithEmail: (email: string, password: string) => Promise; - loginWithOAuth: (provider: string, code: string, state: string) => Promise; + loginWithOAuth: ( + provider: string, + code: string, + state: string, + deviceId?: string | null, + ) => Promise; registerWithEmail: ( email: string, password: string, @@ -295,13 +300,14 @@ export const useAuthStore = create()( await get().checkAdminStatus(); }, - loginWithOAuth: async (provider, code, state) => { + loginWithOAuth: async (provider, code, state, deviceId) => { const campaignSlug = consumeCampaignSlug(); const referralCode = consumeReferralCode(); const response = await authApi.oauthCallback( provider, code, state, + deviceId, campaignSlug, referralCode, );