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

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:
Ilia Denisov
2026-07-03 17:59:33 +02:00
parent 60faa4f064
commit 2c465c01d2
36 changed files with 1131 additions and 16 deletions
+17 -1
View File
@@ -34,6 +34,7 @@ import {
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
@@ -96,6 +97,10 @@ export const app = $state<{
/** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
* screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
accountDeleted: boolean;
/** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
* returned from VK with an auth code. Profile consumes it on mount to finish the link or
* merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
vkLinkPending: VKLinkCallback | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
@@ -145,6 +150,7 @@ export const app = $state<{
profile: null,
blocked: null,
accountDeleted: false,
vkLinkPending: null,
toast: null,
lastEvent: null,
theme: 'auto',
@@ -778,9 +784,19 @@ export async function bootstrap(): Promise<void> {
}
const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
if (vkcb) {
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
// finishes the link or merge on mount.
app.vkLinkPending = vkcb;
navigate('/profile');
} else if (router.route.name === 'login') {
navigate('/');
}
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
+4
View File
@@ -177,6 +177,10 @@ export interface GatewayClient {
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
linkTelegram(data: string): Promise<LinkResult>;
linkTelegramMerge(data: string): Promise<LinkResult>;
/** Link a VK identity from the web: the args are the PKCE outputs of the VK ID
* authorization callback, exchanged for the trusted vk id on the gateway. */
linkVK(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
linkVKMerge(code: string, deviceId: string, codeVerifier: string): Promise<LinkResult>;
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
* unlinked. Returns the refreshed link result (status 'unlinked'). */
linkUnlink(kind: string): Promise<LinkResult>;
+8
View File
@@ -31,6 +31,7 @@ import {
decodeDeleteRequestResult,
encodeGuestLogin,
encodeLinkUnlink,
encodeLinkVK,
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
@@ -466,6 +467,13 @@ describe('codec', () => {
expect(r.kind()).toBe('telegram');
});
it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
expect(r.code()).toBe('the-code');
expect(r.deviceId()).toBe('dev-1');
expect(r.codeVerifier()).toBe('verifier-1');
});
it('round-trips the account-delete confirm + request-result wire', () => {
const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
+12
View File
@@ -739,6 +739,18 @@ export function encodeLinkUnlink(kind: string): Uint8Array {
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
}
export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
const b = new Builder(256);
const c = b.createString(code);
const d = b.createString(deviceId);
const v = b.createString(codeVerifier);
fb.LinkVKRequest.startLinkVKRequest(b);
fb.LinkVKRequest.addCode(b, c);
fb.LinkVKRequest.addDeviceId(b, d);
fb.LinkVKRequest.addCodeVerifier(b, v);
return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
}
export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
const b = new Builder(64);
const c = b.createString(code);
+1
View File
@@ -173,6 +173,7 @@ export const en = {
'profile.guestLocked': 'Sign in with email to manage your profile.',
'profile.linkAccount': 'Link an account',
'profile.linkTelegram': 'Link Telegram',
'profile.linkVK': 'Link VK',
'profile.linked': 'Account linked.',
'profile.merged': 'Accounts merged.',
'profile.mergeTitle': 'Merge accounts?',
+1
View File
@@ -173,6 +173,7 @@ export const ru: Record<MessageKey, string> = {
'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.',
'profile.linkAccount': 'Привязать аккаунт',
'profile.linkTelegram': 'Привязать Telegram',
'profile.linkVK': 'Привязать VK',
'profile.linked': 'Аккаунт привязан.',
'profile.merged': 'Аккаунты объединены.',
'profile.mergeTitle': 'Объединить аккаунты?',
+10
View File
@@ -674,6 +674,16 @@ export class MockGateway implements GatewayClient {
this.profile.telegramLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return emptyLinked();
}
async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
this.profile.isGuest = false;
this.profile.vkLinked = true;
return { ...emptyLinked(), status: 'merged' };
}
async linkUnlink(kind: string): Promise<LinkResult> {
if (kind === 'telegram') this.profile.telegramLinked = false;
if (kind === 'vk') this.profile.vkLinked = false;
+6
View File
@@ -268,6 +268,12 @@ export function createTransport(baseUrl: string): GatewayClient {
async linkTelegramMerge(data) {
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
},
async linkVK(code, deviceId, codeVerifier) {
return codec.decodeLinkResult(await exec('link.vk.confirm', codec.encodeLinkVK(code, deviceId, codeVerifier)));
},
async linkVKMerge(code, deviceId, codeVerifier) {
return codec.decodeLinkResult(await exec('link.vk.merge', codec.encodeLinkVK(code, deviceId, codeVerifier)));
},
async linkUnlink(kind) {
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
},
+122
View File
@@ -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 };
}