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

Статья разбирает безопасность OAuth2 с точки зрения API: какие гранты остались в строю, как защищать код без секретов, как проектировать права, чем токен носителя отличается от токена с доказательством владения, как ротация refresh ловит кражи и что проверять на принимающей стороне. Всё это с кодом, числами и списком правил, которые стоит вешать рядом с CI.

Гранты OAuth2 какие живы какие осуждены и почему

Грант это сценарий получения токена, и современная линейка сжалась до двух рабочих лошадей плюс одна для машин. Авторизационный код с расширенной защитой остаётся единственным сценарием для пользователей: браузер отводит человека к провайдеру, тот возвращает одноразовый код, и сервер обменивает его на токены напрямую, минуя браузер. Грант клиентских учётных записей служит машинам: сервис представляется собственным секретом и получает токен без человека. Грант обновления продлевает доступ, не мучая пользователя повторным входом.

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

Выбор гранта это первое решение безопасности, и оно почти всегда одно: код с защитой для человека, клиентские учётные записи для сервиса, обновление для продления. Любой четвёртый вариант требует письменного оправдания перед командой безопасности, потому что оправдание на словах в этой области не работает.

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

PKCE и защита кода почему публичные клиенты не хранят секреты

Публичный клиент это тот, кто не может хранить секрет: страница в браузере, мобильное приложение, установленное на устройство пользователя. Раньше такие клиенты обходились без секретов и без защиты, и перехваченный код превращался в украденный доступ. Защита владения кодом закрыла прореху: клиент перед стартом генерирует случайную пару из доказательства и его свёртка, свёртка уезжает в запрос, доказательство предъявляется при обмене, и перехваченный код без доказательства бесполезен.

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

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

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

Scopes наименьшие привилегии от именования до согласия пользователя

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

Именование прав обязано читаться с первого взгляда: ресурс, действие, иногда гранулярность. Права вида публичный профиль и записи по проекту переживают годы, а наборы вида доступ к расширенным функциям копят мусор и растут до неотличимости. Гранулярность выбирается по продуктовой осмысленности: право, которое не может быть выдано или отозвано отдельно от соседа, либо слишком широкое, либо слишком мелкое.

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

На стороне API права проверяются на каждом эндпоинте, а не в одной middleware на входе. Разделение прав на чтение и запись по методам запроса, отдельные права на чувствительные операции, и отказ приходит с честным кодом о недостаточном праве, а не с загадочным запретом.

function requireScope(scope) {
  return (req, res, next) => {
    const granted = req.auth.scope.split(" ");
    if (!granted.includes(scope)) {
      return res.status(403).json({ error: "insufficient_scope" });
    }
    next();
  };
}

Токены носителя против токенов с доказательством владения

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

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

Выбор между носителем и привязкой это выбор уровня угрозы: внутренний сервис в закрытом контуре живёт с коротким носителем, клиентская страница в дикой сети заслуживает привязки, и решение записывается в модель угроз один раз, а не в каждом релизе.

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

const { jwtVerify, createRemoteJWKSet } = require("jose");

const JWKS = createRemoteJWKSet(new URL("https://id.example.net/.well-known/jwks.json"));

const { payload } = await jwtVerify(token, JWKS, {
  issuer: "https://id.example.net",
  audience: "api://payments",
  clockTolerance: 5
});

Ротация refresh обнаружение повторов и честная отмена доступа

Токен обновления это самый сильный артефакт потока: живёт долго и открывает дверь без человека. Гигиена начинается с ротации: каждый обмен выдаёт новый токен обновления, старый гасится, и повторное предъявление старого расценивается как кража, после которой гасится вся цепочка. Индикатор компрометации срабатывает по простому правилу: украденная копия и честный клиент конкурируют за один токен, и тот, кто предъявил вторым, поднимает тревогу.

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

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

Журнал использования цепочки это расследование за минуту: кто, откуда, когда и какой токен предъявил. Без журнала тревога повторного предъявления звучит в пустоту, потому что ответить на вопрос чей это был клиент невозможно.

Валидация redirect URI state и границы клиента

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

Параметр состояния остаётся страховкой против подмены запроса: случайное значение уходит с запросом и возвращается с ответом, и несовпадение это отказ без объяснений. Одноразовая метка внутри выдачи дополняет картину на стороне провайдера, и их связка закрывает подмену на двух этажах сразу.

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

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

Жизнь токенов в API аудитория время жизни и наблюдение

Принимающий API отвечает за собственную судьбу токена. Аудитория ограничивает токен именем конкретного сервиса, и токен, выпущенный для соседнего API, отвергается без разговоров, иначе компромисс одного сервиса открывает весь парк. Время жизни проверяется с допуском на рассинхрон часов, алгоритмы сверяются с ожидаемым списком, и заголовок алгоритма не воспринимается как команда.

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

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

Порядок укрепления OAuth2 в API свёрнут в короткую лестницу:

  1. оставить живыми только код с проверкой владения, клиентские учётные записи и обновление;
  2. включить ротацию refresh с индикатором повторного предъявления и журналом цепочек;
  3. сократить жизнь токенов доступа до минут и положиться на обновление;
  4. спроектировать права по глаголам и сущностям с честным экраном согласия;
  5. проверять аудиторию и издателя на каждом запросе со свежими ключами;
  6. сверять обратные вызовы посимвольно и хранить состояние одноразовым;
  7. наблюдать отказы и повторы как первоклассные сигналы безопасности.

Безопасность OAuth2 держится не на секретных знаниях, а на коротком списке скучных правил, применённых целиком. API, который прошёл по этой лестнице один раз, остаётся защищённым годами, потому что каждая новая интеграция наследует рамку, а не начинает переговоры о послаблениях заново. Рамка, применённая целиком, стоит дешевле любой рамки, применённой частично, и это правило переживает смены команд.