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

View File

@@ -127,12 +127,13 @@ 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:') {
parsed = new URL(authorize_url);
} catch {
throw new Error('Invalid OAuth redirect URL');
}
} catch {
if (parsed.protocol !== 'https:') {
throw new Error('Invalid OAuth redirect 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 { 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'));
}
};

View File

@@ -38,7 +38,12 @@ interface AuthState {
loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => 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: (
email: string,
password: string,
@@ -295,13 +300,14 @@ export const useAuthStore = create<AuthState>()(
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,
);