OAuth через Яндекс ID: гайд для Node-приложения
Яндекс 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.