lenec ru

← все посты

OAuth через VK ID: подключение к Next.js приложению

10K

VK ID — отдельная история. Это не классический OAuth провайдер первой свежести: за последние два года VK переделывал API дважды, а часть инструкций в интернете отстаёт от реальности. Я подключала актуальный VK ID OAuth 2.1 на двух Next.js-проектах в этом году. Расскажу пошагово.

Контекст: Next.js 14, App Router, серверные сессии (Better Auth или ручные — не суть). Если у тебя Pages Router или другой фреймворк — общая логика одинаковая, отличия только в синтаксисе обработчиков.

Что такое VK ID

VK ID — это OAuth 2.1 провайдер с поддержкой PKCE. Он живёт на id.vk.com и использует подход с одноразовым device_id вместо привычного state. Поддерживает Authorization Code Flow с PKCE, refresh-токены, выход через провайдера и пр.

Эндпоинты:

  • https://id.vk.com/authorize — экран входа с провайдером.
  • https://id.vk.com/oauth2/auth — обмен кода на токен.
  • https://id.vk.com/oauth2/user_info — данные пользователя.
  • https://id.vk.com/oauth2/logout — выход через провайдера.

Регистрация приложения

Идёшь на id.vk.com и в кабинете разработчика создаёшь приложение. Несколько важных моментов:

  • Тип приложения. «Веб-сайт».
  • Базовый домен. Должен совпадать с твоим прод-доменом без http/https.
  • Redirect URL. Точный путь, на который придёт callback. У меня https://app.example.ru/auth/vk/callback.
  • Доверенный redirect URL. Параллельная настройка, нужна для серверного флоу. Без неё получишь ошибку «redirect_uri does not match».

На странице приложения VK выдаёт два значения: Application ID (число) и Service Token / Client Secret. Service Token нужен для серверных запросов от имени приложения. Для OAuth-callback мы будем использовать client_secret.

VK_CLIENT_ID=12345678
VK_CLIENT_SECRET=...
VK_REDIRECT_URI=https://app.example.ru/auth/vk/callback

PKCE и state

VK ID требует PKCE. Это значит, что мы:

  1. Генерируем случайный code_verifier.
  2. Считаем code_challenge = base64url(sha256(code_verifier)).
  3. Отправляем code_challenge в authorize.
  4. На callback присылаем code_verifier в /token.

Сервер VK сравнивает: если challenge не сходится — отказ. Это защищает от перехвата code в редких сценариях со встроенными браузерами и редиректами.

Шаг 1: redirect на VK ID

Файл app/auth/vk/route.ts:

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import crypto from 'node:crypto';

function base64url(buffer: Buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function GET() {
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = base64url(
    crypto.createHash('sha256').update(codeVerifier).digest()
  );
  const state = base64url(crypto.randomBytes(16));

  cookies().set('vk_oauth_verifier', codeVerifier, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  });
  cookies().set('vk_oauth_state', state, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600,
  });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.VK_CLIENT_ID!,
    redirect_uri: process.env.VK_REDIRECT_URI!,
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 's256',
    scope: 'email phone',
  });

  redirect(`https://id.vk.com/authorize?${params}`);
}

Несколько моментов:

  • code_challenge_method — строго s256, в нижнем регистре. Помню, как один раз поставил S256 и ловил 400.
  • scope — через пробел. email возвращает email, phone — номер телефона. Нужны отдельные модерации в кабинете VK.

Шаг 2: callback

Файл app/auth/vk/callback/route.ts:

import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');
  const deviceId = url.searchParams.get('device_id');
  const error = url.searchParams.get('error');

  if (error) {
    return NextResponse.json({ error }, { status: 400 });
  }

  const cookieStore = cookies();
  const expectedState = cookieStore.get('vk_oauth_state')?.value;
  const verifier      = cookieStore.get('vk_oauth_verifier')?.value;

  if (!expectedState || expectedState !== state || !verifier || !code || !deviceId) {
    return NextResponse.json({ error: 'state_mismatch' }, { status: 400 });
  }
  cookieStore.delete('vk_oauth_state');
  cookieStore.delete('vk_oauth_verifier');

  const tokenBody = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    code_verifier: verifier,
    client_id: process.env.VK_CLIENT_ID!,
    client_secret: process.env.VK_CLIENT_SECRET!,
    redirect_uri: process.env.VK_REDIRECT_URI!,
    device_id: deviceId,
    state,
  });

  const tokenRes = await fetch('https://id.vk.com/oauth2/auth', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: tokenBody,
  });
  if (!tokenRes.ok) {
    return NextResponse.json({ error: await tokenRes.text() }, { status: 500 });
  }
  const token = await tokenRes.json() as {
    access_token: string;
    refresh_token: string;
    expires_in: number;
    user_id: number;
    state: string;
  };

  const infoRes = await fetch('https://id.vk.com/oauth2/user_info', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      access_token: token.access_token,
      client_id: process.env.VK_CLIENT_ID!,
    }),
  });
  if (!infoRes.ok) {
    return NextResponse.json({ error: await infoRes.text() }, { status: 500 });
  }
  const data = await infoRes.json() as { user: VkUser };

  const user = await upsertUserFromVk(data.user, token);
  await createSession(user.id);

  return NextResponse.redirect(new URL('/', req.url));
}

type VkUser = {
  user_id: string;
  first_name: string;
  last_name: string;
  email?: string;
  phone?: string;
  avatar?: string;
  verified?: boolean;
};

Шаг 3: upsert

async function upsertUserFromVk(info: VkUser, token: VkToken) {
  const accountFields = {
    providerId: 'vk',
    accountId: info.user_id,
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    accessTokenExpiresAt: new Date(Date.now() + token.expires_in * 1000),
  };
  const existing = await db.query.account.findFirst({
    where: (a, { and, eq }) => and(eq(a.providerId, 'vk'), eq(a.accountId, info.user_id)),
  });
  if (existing) {
    await db.update(account).set(accountFields).where(eq(account.id, existing.id));
    return await db.query.user.findFirst({ where: eq(user.id, existing.userId) })!;
  }

  const userRow = await db.insert(user).values({
    id: cuid(),
    email: info.email ?? `vk-${info.user_id}@no-email.local`,
    name: `${info.first_name} ${info.last_name}`.trim(),
    image: info.avatar ?? null,
    emailVerified: !!info.email,
  }).returning().then(r => r[0]);

  await db.insert(account).values({ id: cuid(), userId: userRow.id, ...accountFields });
  return userRow;
}

Маленький, но важный момент: VK возвращает email только если пользователь подтвердил его и в scope был email. Если email отсутствует, не падай — у меня в БД для таких случаев плейсхолдер с пометкой «no-email», и потом отдельный шаг просит подтвердить настоящий адрес.

Refresh-токен

Access-токен у VK живёт час. Refresh-токен — год, но одноразовый: при использовании выдаётся новый refresh, старый перестаёт быть валидным. То есть нужно бережно хранить «последний выданный» refresh, иначе порвёшь цепочку.

const body = new URLSearchParams({
  grant_type: 'refresh_token',
  refresh_token: storedRefresh,
  client_id: process.env.VK_CLIENT_ID!,
  device_id: storedDeviceId,
  state: crypto.randomBytes(8).toString('hex'),
});

Возвращается тот же набор: новые access и refresh, та же expires_in. Сразу обновляй обе записи в БД в одной транзакции — иначе при сбое окажешься без рабочего refresh.

Подводные камни

«invalid_grant: device_id required»

VK ID добавил device_id как обязательный параметр в обмен code на token и в refresh. На callback он приходит в query, не забудь его сохранить.

«invalid_request: code_verifier mismatch»

Что-то в кодах PKCE: либо ты считаешь base64 не url-safe (с +, /, =), либо неверный hash. Самое частое — забыл убрать padding =.

SameSite куки и iframe

Если у тебя VK ID one-tap встраивается через виджет, и ты ловишь куки в iframe — нужны SameSite=None; Secure. На обычном full-redirect SameSite=Lax хватает. Я предпочитаю full redirect, виджет лишних граблей подкидывает.

Email не пришёл

Половина пользователей VK не отдаёт email. Дизайн сценария регистрации должен это учитывать: либо просим email на втором шаге, либо разрешаем «вход без почты».

Через Better Auth

В Better Auth VK ID можно подключить через genericOAuth аналогично Яндексу. Главные кастомизации: PKCE включён по умолчанию, нужен ручной getUserInfo с POST-формой.

genericOAuth({
  config: [{
    providerId: 'vk',
    clientId: process.env.VK_CLIENT_ID!,
    clientSecret: process.env.VK_CLIENT_SECRET!,
    authorizationUrl: 'https://id.vk.com/authorize',
    tokenUrl: 'https://id.vk.com/oauth2/auth',
    pkce: true,
    scopes: ['email'],
    getUserInfo: async (tokens) => {
      const r = await fetch('https://id.vk.com/oauth2/user_info', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          access_token: tokens.accessToken,
          client_id: process.env.VK_CLIENT_ID!,
        }),
      });
      const j = await r.json();
      const u = j.user;
      return {
        id: u.user_id,
        email: u.email ?? `vk-${u.user_id}@no-email.local`,
        emailVerified: !!u.email,
        name: `${u.first_name} ${u.last_name}`.trim(),
        image: u.avatar ?? null,
      };
    },
  }],
})

Параметр device_id сейчас нужно добавлять руками в callback handler. Проще всего — обернуть catch-all-роут middleware-ом, который пробрасывает device_id в контекст. Но это уже частный случай, тут зависит от версии Better Auth.

Вывод

VK ID — нормальный, рабочий OAuth, просто с парой нюансов: PKCE обязателен, требуется device_id, refresh-токен одноразовый. Эти три пункта закрывают 90% возможных проблем. Остальное — стандартная OAuth-механика, как и у Яндекса или Гугла.

На моих сервисах с момента подключения проблем не было: пользователи входят, сессия живёт, refresh обновляется в фоне. С учётом популярности VK среди российской аудитории — must-have в любом приложении, рассчитанном на массовый рынок.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.