fix: support VK ID OAuth 2.1 device_id in frontend

- 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)
This commit is contained in:
Fringg
2026-03-02 04:10:05 +03:00
parent 3f050396b8
commit 60f16e64e8
4 changed files with 28 additions and 13 deletions

View File

@@ -174,6 +174,7 @@ export const authApi = {
provider: string, provider: string,
code: string, code: string,
state: string, state: string,
deviceId?: string | null,
campaignSlug?: string | null, campaignSlug?: string | null,
referralCode?: string | null, referralCode?: string | null,
): Promise<AuthResponse> => { ): Promise<AuthResponse> => {
@@ -182,6 +183,7 @@ export const authApi = {
{ {
code, code,
state, state,
device_id: deviceId || undefined,
campaign_slug: campaignSlug || undefined, campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined, referral_code: referralCode || undefined,
}, },

View File

@@ -127,14 +127,15 @@ export default function Login() {
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider); const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
// Validate redirect URL — only allow HTTPS to prevent open redirect // Validate redirect URL — only allow HTTPS to prevent open redirect
let parsed: URL;
try { try {
const parsed = new URL(authorize_url); parsed = new URL(authorize_url);
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect URL');
}
} catch { } catch {
throw new Error('Invalid OAuth redirect URL'); throw new Error('Invalid OAuth redirect URL');
} }
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect URL');
}
saveOAuthState(state, provider); saveOAuthState(state, provider);
window.location.href = authorize_url; window.location.href = authorize_url;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; 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';
@@ -28,6 +28,7 @@ export default function OAuthCallback() {
const [error, setError] = useState(''); const [error, setError] = useState('');
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);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
@@ -35,9 +36,15 @@ export default function OAuthCallback() {
return; return;
} }
// Prevent double-fire from React StrictMode or dependency changes
if (hasRun.current) return;
hasRun.current = true;
const authenticate = async () => { const authenticate = async () => {
const code = searchParams.get('code'); const code = searchParams.get('code');
const urlState = searchParams.get('state'); 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) { if (!code || !urlState) {
setError(t('auth.oauthError', 'Authorization was denied or failed')); setError(t('auth.oauthError', 'Authorization was denied or failed'));
@@ -58,14 +65,13 @@ export default function OAuthCallback() {
} }
try { try {
await loginWithOAuth(saved.provider, code, urlState); await loginWithOAuth(saved.provider, code, urlState, deviceId);
navigate('/', { replace: true }); navigate('/', { replace: true });
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }; const detail =
setError( (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
error.response?.data?.detail || (err instanceof Error ? err.message : null);
t('auth.oauthError', 'Authorization was denied or failed'), setError(detail || t('auth.oauthError', 'Authorization was denied or failed'));
);
} }
}; };

View File

@@ -38,7 +38,12 @@ interface AuthState {
loginWithTelegram: (initData: string) => Promise<void>; loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>; loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
loginWithEmail: (email: string, password: string) => Promise<void>; loginWithEmail: (email: string, password: string) => Promise<void>;
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>; loginWithOAuth: (
provider: string,
code: string,
state: string,
deviceId?: string | null,
) => Promise<void>;
registerWithEmail: ( registerWithEmail: (
email: string, email: string,
password: string, password: string,
@@ -295,13 +300,14 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus(); await get().checkAdminStatus();
}, },
loginWithOAuth: async (provider, code, state) => { loginWithOAuth: async (provider, code, state, deviceId) => {
const campaignSlug = consumeCampaignSlug(); const campaignSlug = consumeCampaignSlug();
const referralCode = consumeReferralCode(); const referralCode = consumeReferralCode();
const response = await authApi.oauthCallback( const response = await authApi.oauthCallback(
provider, provider,
code, code,
state, state,
deviceId,
campaignSlug, campaignSlug,
referralCode, referralCode,
); );