lenec ru

← все посты

OAuth через Яндекс ID: гайд для Node-приложения

19K

Яндекс ID я подключала на нескольких внутренних и внешних сервисах для российских клиентов. Нюансов хватает: документация местами устаревшая, а отдельные шаги нужно делать строго в правильном порядке. Соберу пошаговый гайд, по которому у меня входит за час.

Для примера возьму Node-приложение на Express с серверными сессиями. Логика будет такой же для Hono или Fastify, и сразу скажу про подключение к Better Auth в конце.

Что такое Яндекс ID

Это OAuth 2.0-провайдер от Яндекса. Ты регистрируешь приложение, получаешь ClientID и ClientSecret, и пользователи логинятся через свой аккаунт Яндекса. Поддерживается Authorization Code Flow, refresh token, кастомные scope.

Основные эндпоинты:

  • https://oauth.yandex.ru/authorize — экран логина и согласия.
  • https://oauth.yandex.ru/token — обмен code на access_token.
  • https://login.yandex.ru/info — информация о пользователе по access_token.

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

Идёшь на oauth.yandex.ru и создаёшь приложение. На что обратить внимание:

  • Redirect URI. Нужно указать ровно тот URL, на который придёт код. У меня обычно https://app.example.ru/auth/yandex/callback. На этапе разработки добавляю и http://localhost:3000/auth/yandex/callback.
  • Платформа. Для серверного приложения — «Веб-сервисы».
  • Права (scope). Минимум login:email, login:info. Для аватара — login:avatar. Не запрашивай лишних: пользователи будут пугаться.

После создания на странице приложения видны ClientID и ClientSecret. Кладу в .env:

YANDEX_CLIENT_ID=...
YANDEX_CLIENT_SECRET=...
YANDEX_REDIRECT_URI=http://localhost:3000/auth/yandex/callback

Шаг 1: redirect на экран Яндекса

Endpoint, по которому пользователь нажимает «Войти через Яндекс»:

import express from 'express';
import crypto from 'node:crypto';

const app = express();

app.get('/auth/yandex', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  res.cookie('yandex_oauth_state', state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 10 * 60 * 1000, // 10 минут
  });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.YANDEX_CLIENT_ID!,
    redirect_uri: process.env.YANDEX_REDIRECT_URI!,
    scope: 'login:email login:info login:avatar',
    state,
    force_confirm: 'no', // 'yes' заставит каждый раз показывать диалог
  });

  res.redirect(`https://oauth.yandex.ru/authorize?${params}`);
});

На что обратить внимание:

  • state. Обязательный параметр для защиты от CSRF на OAuth-callback. Кладу в куку и потом сверяю.
  • scope. Через пробел, не запятую. Это нюанс именно Яндекса.
  • force_confirm. На yes Яндекс будет каждый раз показывать диалог подтверждения, даже если пользователь уже разрешил. На no — повторно не показывает. Для UX полезнее no, но если хочется давать пользователю шанс выбрать аккаунт, лучше yes.

Шаг 2: callback и обмен code на token

app.get('/auth/yandex/callback', async (req, res) => {
  const { code, state, error } = req.query;
  if (error) {
    return res.status(400).send(`Yandex OAuth error: ${error}`);
  }

  const expectedState = req.cookies?.yandex_oauth_state;
  if (!expectedState || expectedState !== state) {
    return res.status(400).send('State mismatch');
  }
  res.clearCookie('yandex_oauth_state');

  if (typeof code !== 'string') {
    return res.status(400).send('No code');
  }

  // Обмен code на токен
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    client_id: process.env.YANDEX_CLIENT_ID!,
    client_secret: process.env.YANDEX_CLIENT_SECRET!,
    redirect_uri: process.env.YANDEX_REDIRECT_URI!,
  });

  const tokenRes = await fetch('https://oauth.yandex.ru/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });
  if (!tokenRes.ok) {
    return res.status(500).send(`Token error: ${await tokenRes.text()}`);
  }
  const token = await tokenRes.json() as {
    access_token: string;
    expires_in: number;
    refresh_token?: string;
    token_type: string;
  };

  // Получаем профиль
  const infoRes = await fetch('https://login.yandex.ru/info?format=json', {
    headers: { Authorization: `OAuth ${token.access_token}` },
  });
  if (!infoRes.ok) {
    return res.status(500).send(`Info error: ${await infoRes.text()}`);
  }
  const info = await infoRes.json() as {
    id: string;
    login: string;
    default_email: string;
    real_name?: string;
    display_name?: string;
    default_avatar_id?: string;
  };

  // Тут ищем/создаём пользователя в БД
  const user = await upsertUserFromYandex(info, token);
  await createSession(res, user.id);

  res.redirect('/');
});

Несколько важных моментов:

  • Заголовок Authorization: OAuth <token>. Не Bearer, как в стандартном OAuth 2.0 — у Яндекса свой префикс. Пишу почти каждый раз и каждый раз чуть не поминаю.
  • Аватар. Поле default_avatar_id — это ID, который надо подставить в URL вида https://avatars.yandex.net/get-yapic/<default_avatar_id>/islands-200. Размеры разные: islands-50, islands-200, islands-retina-200 и т.д.
  • Email. default_email может быть в формате login@yandex.ru или ящиком на другом домене. Уникальный идентификатор — id, не email: пользователь может сменить почту.

Шаг 3: upsert пользователя

Функция upsertUserFromYandex у меня выглядит примерно так. На Drizzle и Postgres:

async function upsertUserFromYandex(info: YandexInfo, token: YandexToken) {
  const accountFields = {
    providerId: 'yandex',
    accountId: info.id,
    accessToken: token.access_token,
    refreshToken: token.refresh_token ?? null,
    accessTokenExpiresAt: new Date(Date.now() + token.expires_in * 1000),
  };

  const existing = await db.query.account.findFirst({
    where: (a, { and, eq }) => and(eq(a.providerId, 'yandex'), eq(a.accountId, info.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.default_email,
    name: info.real_name ?? info.display_name ?? info.login,
    image: info.default_avatar_id
      ? `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
      : null,
    emailVerified: true, // Яндекс уже верифицировал email
  }).returning().then(r => r[0]);

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

  return userRow;
}

Тонкость: emailVerified: true. У Яндекса логин невозможен без подтверждённой почты, так что мы можем доверять. Если у тебя в БД уже есть пользователь с таким email, создавать дубликат — плохо. Я связываю существующую запись по email и привязываю к ней Яндекс-аккаунт. Если же email уже занят другим OAuth-аккаунтом, в проде на этом этапе показываю экран «свяжите аккаунты» с подтверждением, чтобы не было фишинга через Яндекс.

Refresh token

Токены Яндекса живут год, refresh приходит сразу. Если хочешь делать API-вызовы от имени пользователя долгое время — храни refresh, обмен на новый access делается тем же /token:

const body = new URLSearchParams({
  grant_type: 'refresh_token',
  refresh_token: storedRefreshToken,
  client_id: process.env.YANDEX_CLIENT_ID!,
  client_secret: process.env.YANDEX_CLIENT_SECRET!,
});

В большинстве сценариев логина мне refresh не нужен — проверил пользователя один раз и забыл. Если же нужно постоянно ходить в Яндекс.Диск или другие API — пляши с refresh.

Особенности и грабли

Несколько redirect URI

Яндекс ID разрешает не больше пяти URI на приложение. Если у тебя много окружений (dev, stage, prod, превью) — заводи отдельное приложение под каждое окружение. Иначе либо упрёшься в лимит, либо ловишь ошибку «Запрещённый redirect_uri».

«Запрещённый redirect_uri»

Самая частая ошибка. Проверяй до символа: протокол, хост, порт, путь, trailing slash. У меня дважды было: в консоли localhost:3000, в коде localhost:3000/. Совпадение должно быть точным.

Заголовок OAuth, а не Bearer

Уже говорила. Каждый раз кто-то на ревью пишет Bearer и ловит 401. Помни про OAuth.

force_confirm и cookie

Если у тебя пользователь жалуется, что после первого логина его «сразу логинит без выбора аккаунта», это из-за того, что Яндекс хранит согласие. Лечится force_confirm=yes, но тогда диалог будет каждый раз. Я обычно ставлю no на login и yes на «сменить аккаунт».

Подключение через Better Auth

Если ты на Better Auth, отдельный код писать не надо: достаточно generic OAuth-плагина или специализированного провайдера, если он уже есть в Better Auth-плагинах.

import { genericOAuth } from 'better-auth/plugins';

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: 'pg' }),
  plugins: [
    genericOAuth({
      config: [{
        providerId: 'yandex',
        clientId: process.env.YANDEX_CLIENT_ID!,
        clientSecret: process.env.YANDEX_CLIENT_SECRET!,
        authorizationUrl: 'https://oauth.yandex.ru/authorize',
        tokenUrl: 'https://oauth.yandex.ru/token',
        userInfoUrl: 'https://login.yandex.ru/info?format=json',
        scopes: ['login:email', 'login:info', 'login:avatar'],
        getUserInfo: async (tokens) => {
          const r = await fetch('https://login.yandex.ru/info?format=json', {
            headers: { Authorization: `OAuth ${tokens.accessToken}` },
          });
          const info = await r.json();
          return {
            id: info.id,
            email: info.default_email,
            emailVerified: true,
            name: info.real_name ?? info.display_name ?? info.login,
            image: info.default_avatar_id
              ? `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
              : null,
          };
        },
      }],
    }),
  ],
});

Главное — в getUserInfo поставить правильный заголовок OAuth. По умолчанию generic OAuth использует Bearer, и Яндекс отвечает 401.

Что у тебя должно получиться в итоге

  • Кнопка «Войти через Яндекс» ведёт на oauth.yandex.ru/authorize.
  • Callback проверяет state, обменивает code на токен, читает /info.
  • Пользователь создаётся или связывается, ставится сессия в куку.
  • На fronend есть кнопка «Отвязать Яндекс», которая удаляет запись из account, оставляя пользователя.

Связать второй OAuth-провайдер (VK ID, GitHub) после этого — вопрос десяти минут. Структура одна и та же: authorize, callback, exchange, info, upsert.

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

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

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