// VK ID web login for linking a VK identity to the current account from a browser. // Unlike the VK Mini App (whose signed launch params authenticate offline), a browser // has no launch signature, so it runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk): // a full-page redirect to VK's hosted login, then a callback to our redirect URL carrying // the authorization code. The gateway completes the confidential code exchange. This is // web-only — a full-page redirect would strand a native Mini App webview, where the // platform identity is already linked anyway. import { insideTelegram } from './telegram'; import { insideVK } from './vk'; function isMock(): boolean { return import.meta.env.MODE === 'mock'; } function vkAppId(): string { return ((import.meta.env.VITE_VK_APP_ID as string | undefined) ?? '').trim(); } function vkRedirectUrl(): string { return ((import.meta.env.VITE_VK_ID_REDIRECT_URL as string | undefined) ?? '').trim(); } /** * vkWebLinkAvailable reports whether the "Link VK" control should be shown: on the plain * web (not inside a Telegram/VK Mini App, where a redirect would break the webview) and * either the mock build or a configured VK ID app id and redirect URL. */ export function vkWebLinkAvailable(): boolean { if (insideTelegram() || insideVK()) return false; if (isMock()) return true; return vkAppId() !== '' && vkRedirectUrl() !== ''; } const STORAGE_KEY = 'vkid.link'; // PendingState is stashed before the redirect and consumed on the callback: the PKCE // verifier (needed for the gateway exchange), the CSRF state, and whether this is an // initial link or the re-authorization for a merge (VK codes are single-use, so a merge // cannot reuse the link's code — it re-authorizes for a fresh one). type PendingState = { verifier: string; state: string; mode: 'link' | 'merge' }; /** VKLinkCallback carries a verified VK ID authorization callback for the gateway exchange. */ export type VKLinkCallback = { code: string; deviceId: string; verifier: string; mode: 'link' | 'merge' }; // b64url encodes bytes as base64url without padding (the PKCE / VK ID alphabet). function b64url(bytes: Uint8Array): string { let s = ''; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function randomToken(bytes: number): string { const a = new Uint8Array(bytes); crypto.getRandomValues(a); return b64url(a); } async function challengeFrom(verifier: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); return b64url(new Uint8Array(digest)); } /** * startVKLink begins VK ID web authorization by redirecting the whole tab to VK's hosted * login. The PKCE verifier and CSRF state survive the redirect in sessionStorage; VK * returns to VITE_VK_ID_REDIRECT_URL with ?code&device_id&state, processed on the next * boot via pendingVKLink. mode distinguishes an initial link from a merge re-auth. The * mock build never leaves the SPA. */ export async function startVKLink(mode: 'link' | 'merge'): Promise { if (isMock()) return; const verifier = randomToken(48); // 64 base64url chars (VK ID requires 43..128) const state = randomToken(24); // 32 base64url chars (VK ID requires >= 32) const challenge = await challengeFrom(verifier); const pending: PendingState = { verifier, state, mode }; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pending)); const url = new URL('https://id.vk.com/authorize'); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', vkAppId()); url.searchParams.set('redirect_uri', vkRedirectUrl()); url.searchParams.set('state', state); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('scope', 'vkid.personal_info'); location.assign(url.toString()); } // stripCallbackQuery removes the OAuth query params from the URL so a refresh does not // re-process the callback, keeping the path and any hash route intact. function stripCallbackQuery(): void { if (typeof history !== 'undefined' && history.replaceState) { history.replaceState(null, '', location.pathname + location.hash); } } /** * pendingVKLink extracts and clears a VK ID authorization callback from the current URL, * verifying the returned state against the one stored before the redirect (CSRF). It * returns null on a normal load or when the state does not match, and always strips the * query so a refresh does not re-run it. */ export function pendingVKLink(): VKLinkCallback | null { if (typeof location === 'undefined') return null; const q = new URLSearchParams(location.search); const code = q.get('code'); const deviceId = q.get('device_id'); const returnedState = q.get('state'); if (!code || !deviceId || !returnedState) return null; const raw = sessionStorage.getItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY); stripCallbackQuery(); if (!raw) return null; let pending: PendingState; try { pending = JSON.parse(raw) as PendingState; } catch { return null; } if (pending.state !== returnedState) return null; return { code, deviceId, verifier: pending.verifier, mode: pending.mode }; }