Первый же запрос к Reddit вернул 429 Too Many Requests. Первый. Не сотый, не после цикла, а самый первый за всё время существования приложения.

Я честно полез смотреть, где у меня ретраи и не долбит ли что-нибудь в фоне. Ничего не долбило. Через какое-то время выяснилось, что Reddit просто не любит клиентов без внятного User-Agent: библиотечный дефолт вида node-fetch/1.0 он считает ботом и отвечает 429, хотя причина не имеет к частоте запросов никакого отношения. Поменял заголовок на осмысленный — заработало с первой попытки.

Это была примерно двадцатая по счёту странность, и где-то на ней я перестал удивляться.

Есть спецификация OAuth 2.0, есть OpenID Connect поверх неё, обе описаны подробно и живут не первый год. По ним написаны десятки библиотек. Кажется, что подключить кнопку «Войти через» — работа на полчаса: берёшь готовый клиент, подставляешь три эндпоинта, готово. Так оно и выглядит ровно до второго провайдера.

Я собрал единую точку входа на 18 провайдеров и 26 способов авторизации — от VK и Сбера до Telegram и Steam. Почти каждый соблюдает стандарт по-своему: у одного обязательный самодельный заголовок, у другого секрет лежит в теле запроса вместо базовой авторизации, у третьего вместо OAuth протокол пятнадцатилетней давности, а у четвёртого секрет вообще не строка, а JWT, который надо перевыпускать. Ниже — каталог этих отклонений с кодом и разбор того, кто отклоняется сильнее всех.

Оговорюсь про рамки: это не гайд «как настроить вход через VK», по каждому провайдеру есть официальная документация. Это про то, чего в документации нет — почему единый обработчик на всех написать нельзя.

КПДВ
КПДВ

Почему вообще пришлось лезть в это

Контекст, чтобы было понятно, откуда задача. Я делаю Синапсия Авторизацию (id.synapsea.agency) — прослойку, которая прячет всех этих провайдеров за одним API: клиент подключает один эндпоинт и получает кнопки «Войти через VK / Яндекс / Telegram / что угодно», не разбираясь, как каждый устроен внутри. Сервис в открытом бета-тесте, и весь материал ниже накопился именно оттуда.

Смысл продукта ровно в том, чтобы описанная дальше боль оставалась на моей стороне. Так что этот текст — инвентаризация того, что пришлось спрятать за одной кнопкой.

Как выглядит «стандартный» OAuth

Чтобы было с чем сравнивать. Пользователь жмёт «Войти через X», ваш сервер формирует ссылку на страницу авторизации провайдера с client_id, redirect_uri, state и списком scope. Пользователь логинится, провайдер редиректит обратно с временным code. Ваш сервер меняет code на access_token POST-запросом на token-эндпоинт, авторизуясь парой client_id:client_secret. С этим токеном запрашивает профиль. Четыре шага, три эндпоинта. В OpenID Connect добавляется id_token — подписанный JWT, который проверяется локально, без похода за профилем.

Эталон здесь Google. Discovery-документ лежит по стандартному адресу, из него достаются все эндпоинты, id_token проверяется по опубликованному набору ключей — то есть клиент можно написать вообще не читая документацию, просто следуя спецификации:

const meta = await fetch('https://accounts.google.com/.well-known/openid-configuration')
  .then((r) => r.json());

const query = new URLSearchParams({
  response_type: 'code',
  client_id: clientId,
  redirect_uri: redirectUri,
  state,
  nonce,
  scope: 'openid profile email',
  code_challenge: challenge,
  code_challenge_method: 'S256',
});

const url = `${meta.authorization_endpoint}?${query}`;

Если бы все были как Google, этой статьи не было бы, а мой сервис никому не был бы нужен. Проблема в том, что как Google — примерно никто.

Кстати, state и nonce тут не для красоты, и это единственное место, где я скажу что-то нравоучительное. state — защита от CSRF: значение генерируется перед редиректом, кладётся в подписанную куку и сверяется на возврате, иначе злоумышленник подсунет жертве свой code и привяжет её аккаунт к своему. nonce — защита от повтора id_token. Оба нужны всегда, у всех провайдеров, и оба регулярно выпиливают из туториалов «для краткости».

Каталог отклонений

Вот вся картина одной таблицей, а дальше по возрастанию тяжести.

Провайдер

Протокол

Главное отклонение от стандарта

Google

OIDC

нет отклонений, эталон

Discord

OAuth 2.0

стандартно

Twitch

OAuth 2.0

стандартно

GitHub

OAuth 2.0

scope через запятую, токен без срока

Reddit

OAuth 2.0

требует User-Agent, иначе 429

Яндекс ID

свой OAuth 2.0

не OIDC, заголовок OAuth вместо Bearer

Alfa ID

OIDC

авторизация и API на разных доменах

T-ID

OAuth 2.0

client_secret в теле запроса профиля

VK ID

OAuth 2.1

device_id и state в теле обмена токена

Сбер ID

OIDC

заголовок RqUID в каждом запросе

Apple

OIDC

client_secret это самоподписанный JWT ES256

Steam

OpenID 2.0

не OAuth вообще, протокол из 2010-х

Telegram

три сразу

OIDC + два разных HMAC-механизма

ЕСИА

OAuth + ГОСТ

client_secret это ГОСТ-подпись, нужен CryptoPro

Насколько каждый провайдер отклоняется от «учебного» OAuth
Насколько каждый провайдер отклоняется от «учебного» OAuth

GitHub

scope в OAuth разделяется пробелами. GitHub разделяет запятыми. Одна эта деталь ломает наивную реализацию, которая собирает строку скоупов единообразно для всех, причём ломает молча: запрос проходит, пользователь видит экран согласия, а прав у вас не тех, что вы просили.

Второе: токены GitHub по умолчанию бессрочные, expires_in в ответе просто нет. Код, который везде ждёт это поле и считает время протухания, на undefined посчитает что-нибудь вроде Date.now() + NaN и решит, что токен истёк в момент выдачи.

А третье я не предвидел совсем. Профиль от GitHub может приехать с email: null — если пользователь спрятал почту в настройках, что делают многие. Почта при этом есть, просто отдаётся отдельным эндпоинтом и только при наличии скоупа:

let { email } = profile;

if (!email) {
  const emails = await gh('/user/emails');           // нужен scope user:email
  email = emails.find((e) => e.primary && e.verified)?.email ?? null;
}

Проверять надо именно primary && verified. Просто «первый из списка» — это может оказаться старый рабочий адрес, на который человек не заходил три года.

Reddit

С этого я начал статью, так что коротко: без осмысленного User-Agent прилетает 429, и никакой связи с частотой запросов у этого нет. Reddit хочет заголовок в собственном формате, где указано, что вы за приложение и кто автор:

User-Agent: web:synapsea-auth:v1.0 (by /u/username)

Провайдер, который различает клиентов по User-Agent, — редкость, и время уходит целиком на то, чтобы вообще заподозрить эту гипотезу. Код 429 уводит в сторону мастерски: ты идёшь читать про rate limits, находишь их документацию, считаешь свои запросы, ничего не сходится.

Яндекс

Собственный OAuth 2.0 без OpenID Connect: discovery-документа нет, id_token нет, профиль только запросом к API. И заголовок авторизации нестандартный — не Bearer, а OAuth:

const profile = await fetch('https://login.yandex.ru/info?format=json', {
  headers: { Authorization: `OAuth ${accessToken}` },   // не Bearer
}).then((r) => r.json());

Мелочь, но код, который переиспользует один способ авторизации запроса для всех, на Яндексе спотыкается. Причём спотыкается с 401, который выглядит как «токен невалидный», и вы идёте перепроверять обмен кода, а он в порядке.

Ещё у Яндекса есть «мгновенный вход» — виджет, отдающий токен прямо во фронтенд через postMessage, минуя редирект. Токен всё равно надо проверить серверным запросом к профилю, иначе его подделает кто угодно, так что это отдельная ветка логики рядом с обычным потоком, а не замена ему.

Steam

Steam до сих пор работает на OpenID 2.0 — протоколе, который устарел больше десяти лет назад и не имеет с OAuth 2.0 ничего общего, кроме слова «open» в названии. Никакого client_secret, никакого обмена кода на токен, никакого профиля по токену. Вместо этого вам возвращают набор openid.*-параметров, и вы отправляете их обратно в Steam, чтобы он подтвердил, что подписывал именно он.

const check = new URLSearchParams(query);
check.set('openid.mode', 'check_authentication');   // возвращаем всё как есть

const body = await fetch('https://steamcommunity.com/openid/login', {
  method: 'POST',
  body: check,
}).then((r) => r.text());

if (!/is_valid\s*:\s*true/.test(body)) throw new Error('steam: bad signature');

const steamId = query['openid.claimed_id'].match(/\/(\d+)$/)?.[1];

Этот шаг обязателен, и его любят пропускать: без него claimed_id подставляется руками в адресной строке и вы пускаете кого угодно под любым SteamID. Ответ приходит не JSON, а текстом в формате key:value по строкам — привет из 2010-х. И steamId приходится выковыривать регуляркой из хвоста URL, потому что отдельного поля с идентификатором в ответе нет.

Apple

У Apple client_secret — не строка из конфига. Это JWT, который вы генерируете сами, подписываете ES256 своим приватным ключом из личного кабинета, и живёт он максимум полгода:

const clientSecret = jwt.sign({}, privateKeyP8, {
  algorithm: 'ES256',
  keyid: keyId,                              // Key ID из Apple Developer
  issuer: teamId,                            // Team ID
  audience: 'https://appleid.apple.com',
  subject: clientId,                         // Services ID
  expiresIn: '180d',                         // жёсткий потолок — 6 месяцев
});

То есть секрет надо перевыпускать в рантайме, и реализация, считающая его константой, с Apple не работает в принципе. Это ещё ладно, это хотя бы написано в документации.

А вот чего я не ожидал — так это двух вещей подряд. Первая: если запросить scope=name email, Apple переключает response_mode на form_post и присылает результат POST-запросом на ваш redirect_uri, а не GET-редиректом. Роут, написанный под GET, просто отдаёт 404, и по логам совершенно непонятно, что произошло: пользователь у Apple авторизовался, к вам вернулся, и потерялся.

Вторая: имя пользователя Apple отдаёт только при самой первой авторизации, в том самом POST, и больше никогда. Отозвали доступ, зашли заново — имени опять не будет, там останется только sub и почта. Если вы его не сохранили в первый раз, восстановить неоткуда. Я это узнал на тестовом аккаунте, где успел покликать «войти» раз пятнадцать, и потом полчаса не мог понять, почему имя приходило один раз, а дальше нет.

Плюс почта может оказаться скрытым релеем вида xxxx@privaterelay.appleid.com. Она рабочая, письма через неё доходят, но использовать её как ключ для склейки аккаунтов между провайдерами нельзя — у одного человека в Google и в Apple будут разные адреса.

VK ID

VK ID построен на OAuth 2.1 с обязательным PKCE, и это как раз современно и правильно. Но на обмене кода на токен он требует в теле два поля, которых в спецификации нет:

const body = new URLSearchParams({
  grant_type: 'authorization_code',
  code: params.code,
  client_id: params.clientId,
  client_secret: params.clientSecret,
  redirect_uri: params.redirectUri,
  code_verifier: params.codeVerifier,
  device_id: params.deviceId,   // не по стандарту
  state: params.state,          // не по стандарту, state в теле обмена
});

device_id приезжает в query вместе с code и его надо не потерять по дороге. state в теле обмена — вообще концептуально странно: state придуман для проверки на редиректе, на серверном обмене ему делать нечего.

Дальше профиль. Он берётся POST-запросом, где client_id передаётся в теле ещё раз, отдельно от токена — то есть идентификатор, который вы уже использовали на всех предыдущих шагах, всплывает снова там, где его не ждёшь:

const user = await fetch('https://id.vk.com/oauth2/user_info', {
  method: 'POST',
  body: new URLSearchParams({ client_id: clientId, access_token: accessToken }),
}).then((r) => r.json());

И отдельная ветка — silent token из мобильного SDK: свой grant exchange_silent_token, требующий uuid рядом с самим токеном. VK ID при этом единая точка входа сразу для VK, Mail.ru и Одноклассников, так что три провайдера из моих восемнадцати частично делят механику — приятное исключение на общем фоне.

Сбер ID

Сбер требует в каждом запросе к API уникальный заголовок RqUID — 32 шестнадцатеричных символа, новый на каждый вызов. Забыли — запрос не проходит. Причём это буквально UUID без дефисов, и первая мысль «передам просто UUID» не работает:

const rqUID = randomUUID().replace(/-/g, '');   // ровно 32 hex, без дефисов

На userInfo нужен вдобавок ещё один такой же заголовок под другим именем — x-introspect-rquid. Два уникальных идентификатора запроса в одном запросе. Зачем — не знаю до сих пор.

Плюс каждый этап потока живёт на своём хосте: авторизация на одном домене, токены на другом, профиль на третьем. Конфиг провайдера, который у всех остальных состоит из трёх путей на одном базовом URL, здесь состоит из трёх разных базовых URL.

Т-Банк и Альфа

Т-Банк (бывший Tinkoff ID) на обмене кода авторизуется нормально, по HTTP Basic. А userInfo — POST, где client_id и client_secret кладутся в тело формы, при том что в заголовке уже есть Bearer-токен. Провайдер хочет и токен, и пару креденшелов одновременно, в разных местах одного запроса.

У Alfa ID точка авторизации и API-эндпоинты разнесены по двум доменам: один для редиректа пользователя, другой для серверных запросов. На фоне остального мелочь, но ещё одна вещь, которую держишь в голове.

Где живёт нестандартность: банки и госсервисы РФ отклоняются сильнее всего
Где живёт нестандартность: банки и госсервисы РФ отклоняются сильнее всего

Telegram: отдельная глава

Если бы я составлял рейтинг «сколько времени съел каждый провайдер», Telegram взял бы первое место с большим отрывом. Не потому что сложный, а потому что это три разных механизма авторизации под одним именем, и между собой они несовместимы.

Методов входа на провайдера: Telegram выбивается из всех
Методов входа на провайдера: Telegram выбивается из всех

Login Widget — старый способ, который до сих пор повсюду. Telegram отдаёт объект с полями пользователя и полем hash. Проверка собственная: собрать все поля кроме hash в строку key=value, отсортированную по ключу и склеенную через \n, взять SHA-256 от токена бота, и этим как ключом посчитать HMAC-SHA-256 от строки. Совпало — данные подлинные. Никакого OAuth, никакого токена, чистая криптография на вашей стороне.

Mini App — приложение внутри Telegram присылает строку initData. Проверка похожая, но не такая же: HMAC здесь берётся дважды. Сначала считается ключ как HMAC от строки-константы "WebAppData" и токена бота, и уже этим ключом подписывается строка данных.

Разница ровно в одной строке, и она стоила мне вечера:

// Login Widget: ключ = SHA256(bot_token)
const secretKey = createHash('sha256').update(botToken).digest();

// Mini App: ключ = HMAC("WebAppData", bot_token) — другая операция и другой порядок
const secretKey = createHmac('sha256', 'WebAppData').update(botToken).digest();

Обе строки выглядят правдоподобно, обе компилируются, и обе дают на выходе одинаковое «подпись неверна», если применить не к тому механизму. Никакой подсказки, в чём именно дело, вы не получите — HMAC либо сошёлся, либо нет.

Отладил я это в итоге тупо: захардкодил известный валидный initData из документации и гонял по нему обе схемы, пока одна не сошлась. До этого честно перечитывал свой код сборки строки данных, потому что был уверен, что напутал с сортировкой ключей. Сортировка была в порядке.

Заодно про то, о чём в туториалах пишут редко: у обоих механизмов в данных есть auth_date, и его надо проверять на свежесть. Подпись-то вечная. Перехватили один раз валидный набор полей — и логинитесь по нему хоть через год, если срок никто не смотрит:

const age = Math.floor(Date.now() / 1000) - Number(data.auth_date);
if (age > 86400) throw new Error('telegram: auth_date too old');

OIDC — третий механизм, новый и наконец-то стандартный: Authorization Code с PKCE, id_token в виде JWT, проверка по опубликованным ключам. Единственный из трёх, кто похож на остальной мир.

Проблема не в том, что способов три, а в том, что пользователя они возвращают по-разному. OIDC отдаёт идентификатор и строкой в sub, и числом в id. Виджет и Mini App — только числом. Чтобы один человек, зашедший разными путями, оставался для системы одним пользователем, идентификаторы приходится приводить к общему виду. Не приведёшь — и живой человек расплодится на три аккаунта в зависимости от того, откуда зашёл. Вот это сведение трёх механизмов в один согласованный профиль и было самой муторной частью проекта: не написать каждый по отдельности, а сделать так, чтобы снаружи они выглядели одинаково.

ЕСИА: стена, на которой я остановился

Госуслуги стоят особняком, и рассказать о них стоит как о примере интеграции, которую нельзя сделать «просто написав код».

ЕСИА не пускает по обычному client_secret. Секрет там — это ГОСТ-подпись: строку из параметров запроса надо подписать по ГОСТ Р 34.10-2012, а для этого нужен CryptoPro CSP или совместимая криптобиблиотека, квалифицированная электронная подпись и регистрация вашей системы как информационной в среде ЕСИА. То есть задача звучит не как «допишите обработчик», а как «получите КЭП, поднимите криптопровайдера, зарегистрируйтесь как ИС».

Для одного сервиса на старте это неподъёмный порог. Я написал заготовку провайдера, упёрся ровно в эту стену и в итоге убрал ЕСИА из списка совсем — поэтому провайдеров восемнадцать, а не девятнадцать. Обещать вход, за которым стоит бюрократический квест с электронной подписью, нечестно, а держать нерабочую заглушку в продакшене тем более.

Оставил я её в тексте по одной причине: это лучшая иллюстрация того, как далеко «российский OAuth» уходит от собственно OAuth. Иногда ответ на вопрос «как подключить провайдера» звучит как «получите квалифицированную электронную подпись». И это, пожалуй, главный вывод про ландшафт в целом: чем ближе провайдер к банку или государству, тем дальше он от стандарта. Зарубежные сервисы в массе укладываются в OAuth 2.0 с мелкими отличиями, российские банки навешивают свои заголовки, а госсервис уходит в криптографию по национальному стандарту, где обычный OAuth-клиент бесполезен.

Что в итоге получается в коде

Наивная архитектура «один обработчик для всех, разница только в трёх URL» умирает на втором провайдере. Работающая — это набор независимых адаптеров с общим интерфейсом на входе и выходе, где каждый знает про странности своего провайдера, а наружу отдаёт одинаковый нормализованный профиль:

interface OAuthAdapter {
  buildAuthUrl(ctx: AuthContext): string;
  exchangeCode(ctx: AuthContext, query: Query): Promise<TokenSet>;
  fetchProfile(tokens: TokenSet): Promise<NormalizedProfile>;
}

interface NormalizedProfile {
  providerId: string;        // всегда строка, даже если провайдер отдал число
  email: string | null;      // null — нормальное значение, а не ошибка
  phone: string | null;
  displayName: string | null;
  avatarUrl: string | null;
  raw: unknown;              // сырой ответ, на случай если что-то понадобится
}

Поля email и phone тут нарочно nullable. Первая версия у меня требовала почту обязательно, и это развалилось на Telegram, который её не отдаёт вообще, а потом ещё раз на GitHub со спрятанным адресом. Идентификатор — всегда строка, даже когда провайдер прислал число: приводить число к строке безопасно, обратно уже нет, и в базе одно поле вместо двух.

Различается между провайдерами буквально всё: способ авторизации запроса (Basic, Bearer, заголовок OAuth, HMAC-подпись), место, где живёт client_secret (в заголовке, в теле, генерируется на лету), формат идентификатора (строка, число, оба сразу), и сам протокол (OAuth 2.0, OAuth 2.1, OIDC, OpenID 2.0, своя HMAC-схема). Общим удаётся сделать только выходной формат.

Провайдер

Тип user id

Авторизация запроса к API

Google

строка (sub)

Bearer

Яндекс ID

число

OAuth <token>

VK ID

число

токен + client_id в теле

Telegram OIDC

строка и число сразу

проверка JWT локально

Telegram Widget

число

HMAC-подпись

Сбер ID

строка (sub)

Bearer + RqUID

T-ID

строка (sub)

Bearer + client_secret в теле

Steam

число (SteamID64)

подпись OpenID 2.0

Восемь строк — восемь разных сочетаний, и это только два поля из десятка.

Итоговая сетка — под каждой иконкой свой набор методов входа
Итоговая сетка — под каждой иконкой свой набор методов входа

Короткий чеклист, если будете делать сами

Если вам предстоит подключать больше двух-трёх провайдеров, слой адаптеров стоит заложить сразу, даже когда первых провайдеров два и они похожи. Третий всё сломает, и переписывать под него всю систему обиднее, чем сразу написать на один интерфейс больше.

Профиль нормализуйте на границе. Провайдеры отдают данные в несовместимых форматах и под разными именами полей, и если это протечёт внутрь системы, вы получите условия вида «если это Telegram, то посмотри ещё вот сюда» в бизнес-логике. Чинить такое потом дорого.

На одного провайдера закладывайте несколько способов входа. У VK, Яндекса, Telegram, Google и Apple их больше одного, они не взаимозаменяемы, и идентификаторы из разных механизмов одного провайдера надо уметь сводить к одному человеку — иначе пользователь размножится.

client_secret не всегда статичная строка: у Apple это JWT со сроком жизни, у ЕСИА ГОСТ-подпись. Не храните его как константу в конфиге, заложите вызов функции.

И раз уж вы держите чужие client_secret от двух десятков провайдеров, шифруйте их в покое. Это самая чувствительная часть системы: утечка одного секрета — это возможность выпускать токены от имени приложения клиента.

Итог

Стандарт OAuth 2.0 существует, работает и хорошо описан. Проблема не в стандарте, а в том, что реальные провайдеры трактуют его каждый по-своему: кто добавляет обязательный заголовок, кто кладёт секрет не туда, где принято, кто приносит собственную криптографию или протокол прошлого десятилетия.

Из 18 провайдеров ровно по учебнику работает один — Google. Ещё несколько отклоняются на мелочи. Банки, госсервисы и Telegram живут каждый в своей вселенной, и единственный способ свести их вместе — написать под каждого отдельный адаптер и спрятать разницу за общим интерфейсом.

Собственно, ради того, чтобы эту разницу не разбирал каждый по отдельности, сервисы единой авторизации и существуют. Что там внутри — теперь видно.

Синапсия Авторизация сейчас в открытом бета-тесте: 18 провайдеров, 26 методов входа, подключение одним API-запросом. Попробовать на своём проекте — id.synapsea.agency, тестовый доступ бесплатный. И отдельно: если поломаете что-нибудь и пришлёте внятный баг-репорт — за подтверждённые находки я продлеваю подписку, так что покопаться в чужой авторизации можно с пользой.

Процесс разработки и прочие свои проекты пишу в личном канале — https://t.me/synapseaa. Там же кидаю находки вроде тех, что в этой статье.

Если подключали что-то из этого списка и ловили свои грабли — расскажите в комментариях. Особенно интересны провайдеры, которых у меня нет: маркетплейсы, региональные банки, что-нибудь экзотическое.