feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user