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 свёрнут в короткую лестницу:
- оставить живыми только код с проверкой владения, клиентские учётные записи и обновление;
- включить ротацию refresh с индикатором повторного предъявления и журналом цепочек;
- сократить жизнь токенов доступа до минут и положиться на обновление;
- спроектировать права по глаголам и сущностям с честным экраном согласия;
- проверять аудиторию и издателя на каждом запросе со свежими ключами;
- сверять обратные вызовы посимвольно и хранить состояние одноразовым;
- наблюдать отказы и повторы как первоклассные сигналы безопасности.
Безопасность OAuth2 держится не на секретных знаниях, а на коротком списке скучных правил, применённых целиком. API, который прошёл по этой лестнице один раз, остаётся защищённым годами, потому что каждая новая интеграция наследует рамку, а не начинает переговоры о послаблениях заново. Рамка, применённая целиком, стоит дешевле любой рамки, применённой частично, и это правило переживает смены команд.