OAuth 2.1: что реально изменилось и что значит его «поддерживать»

OAuth 2.1: что реально изменилось и что значит его «поддерживать»

OAuth 2.1 — все еще черновик, а не RFC, и ничего нового он не добавляет. Он вычеркивает из OAuth 2.0 то, что годами порождало одни и те же уязвимости. Что закрывает каждое удаление и что должно означать «соответствие OAuth 2.1» на практике.

OAuth 2.1 — не новый протокол. В нем нет нового grant type, нового endpoint, нового формата токена. Спецификация берет OAuth 2.0 плюс полтора десятка лет best-current-practice RFC (PKCE, Security BCP, Browser-Based Apps BCP) и сводит их в один документ, а заодно вычеркивает из 2.0 то, ради обхода чего эти RFC и писались. Сейчас это draft-ietf-oauth-v2-1-15 (март 2026), все еще Internet-Draft IETF, а не утвержденный RFC, и в этом статусе документ находится с 2020 года. Заявления «мы поддерживаем OAuth 2.1» стоит воспринимать соответственно: указать на номер версии нельзя, есть только список того, что вычеркнули.

Вычеркнутое важнее формулировок. За каждым пунктом стоит конкретная уязвимость, которая продолжала всплывать в реальных системах спустя годы после выхода OAuth 2.0, залатанная по частям расширяющими RFC, которые большинство реализаций так и не прочитали. OAuth 2.1 просто делает эти патчи обязательными, убирая уязвимый путь целиком.

Что реально изменилось

OAuth 2.0OAuth 2.1
PKCEОпционален, на практике только для мобильныхОбязателен для всех клиентов, включая серверные
code_challenge_method=plainРазрешенУбран, остался только S256
Implicit flow (response_type=token)ОпределенУдален
Resource Owner Password CredentialsОпределенУдален
Ротация refresh tokenНе оговоренаОбязательна для публичных клиентов
Bearer-токен в query stringРазрешенЗапрещен

PKCE становится обязателен, plain исчезает

PKCE придумали под одного конкретного атакующего: вредоносное приложение на том же устройстве, что и легитимный публичный клиент, способное зарегистрировать ту же custom URI scheme и перехватить редирект. Без PKCE атакующий, перехвативший authorization code, обменивает его на токен напрямую. Механизм привязывает code к секрету, который легитимное приложение сгенерировало еще до редиректа.

code_verifier  = random_string(43-128 chars)      # хранится у клиента
code_challenge = BASE64URL(SHA256(code_verifier)) # уходит в /authorize

GET /authorize?...&code_challenge=E9Melhoa2...&code_challenge_method=S256

POST /token
grant_type=authorization_code&code=...&code_verifier=dBjftJeZ...

У атакующего, перехватившего code, code_verifier не появляется, поэтому обмен не проходит.

OAuth 2.0 ограничивал требование публичными клиентами, исходя из вполне разумного на тот момент допущения: у конфиденциального клиента есть client secret, и он уже мешает обменять код кому-то еще. Проблема в том, что для обмена перехваченным кодом атакующему вообще не нужно аутентифицироваться как клиент — достаточно достучаться до token endpoint раньше легитимного приложения, а конфиденциальные клиенты сегодня редиректят через браузер ничуть не реже публичных (серверные веб-приложения, BFF). OAuth 2.1 требует PKCE для всех типов клиентов без исключений.

Заодно пропадает code_challenge_method=plain из исходного PKCE RFC (там code_challenge — это просто верификатор без хеширования). plain существовал для клиентов, которые не могли посчитать SHA-256, и это перестало быть реальным ограничением, как только у каждой мобильной платформы появился нативный crypto API. Сохранять эту опцию значило держать лазейку, из-за которой реализация PKCE формально соответствовала спецификации, но от перехвата кода не защищала: атакующий, получивший challenge, получал заодно и верификатор.

Implicit flow: удален, а не просто не рекомендован

Implicit flow (response_type=token) возвращал access token прямо во фрагменте redirect URI, без отдельного шага обмена. Из размещения bearer-токена в URL fragment следуют три последствия:

Authorization Code + PKCE закрывает все случаи, ради которых существовал implicit: SPA получает code через редирект и обменивает его из JavaScript под защитой PKCE, нативное приложение делает то же самое через системный браузер. Типа клиента, которому был нужен именно implicit, а не эта связка, не осталось.

ROPC: удален по причине, по которой вообще существует OAuth

Resource Owner Password Credentials принимал логин и пароль пользователя как поля формы прямо на клиенте и обменивал их на токен напрямую. Это не то же самое, что недоработка вроде отсутствия PKCE или implicit — это прямое противоречие смыслу OAuth как протокола, отдельного от простого «приложение спрашивает ваш пароль». Пароль пользователя становится виден любому клиенту, для которого включен этот flow, причем навсегда, без какого-либо scoping и без способа отличить «клиент продлевает мою сессию» от «клиент делает что-то с моим аккаунтом, имея полный пароль».

Реальный сценарий ROPC (устройства без браузера: телевизоры, CLI-инструменты) закрывает Device Authorization Grant. Пользователю показывают код и адрес, который нужно открыть на другом устройстве, а исходное устройство опрашивает token endpoint, пока это не будет сделано. Устройство, которому нужен токен, пароль вообще не видит.

Ротация refresh token закрывает окно тихого повтора

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

Интересен случай, когда украденный токен используют первым. Следующая попытка обновления у легитимного клиента тогда проваливается — его refresh token уже ротировали из-под него атакующим. Именно этот отказ и есть сигнал обнаружения: если refresh token отклонили как уже использованный, значит, один и тот же токен был на руках у двух сторон, а такого не должно происходить, если токен не утек. Что реализация делает с этим сигналом (молча выдает новый токен или трактует его как признак компрометации и убивает всю сессию), спецификация оставляет на усмотрение разработчика; где именно проходит эта граница у Versola, смотрите ниже в разделе о реализации.

Bearer-токен уходит из URL

Передача access_token в query-параметре кладет токен всюду, куда попадает URL: в логи доступа веб-сервера, в историю браузера, в заголовок Referer, уходящий на сторонние ресурсы той же страницы, в логи прокси на всем пути запроса. Ни одна из этих систем не спроектирована обращаться с query-параметром как с секретом. OAuth 2.1 требует заголовок Authorization: Bearer <token>, который ни один из перечисленных слоев по умолчанию не логирует.

Где «поддержка OAuth 2.1» тихо ломается

Заявить о соответствии и реально закрыть эти дыры — разные вещи. Вот где реализации чаще всего ошибаются:

Чек-лист для оценки реализации OAuth

Как это реализовано в Versola

code_challenge и code_challenge_method — обязательные параметры любого запроса /authorize, который разбирает сервис auth в Versola. Ветки, пропускающей эту проверку для какого-то типа клиента, нет: запрос без одного из параметров падает с invalid_request до того, как выдан authorization code. code_challenge_method принимает ровно одно значение, S256; любое другое, включая plain, падает с CodeChallengeMethodInvalid. code_verifier — обязательное поле в декодере authorization-code granта на token endpoint, так что сам обмен обеспечивает PKCE независимо от того, что проверил /authorize.

response_type принимает code или code id_token и ничего больше — token не входит в набор значений, которые распознает парсер, так что implicit flow не отключенная фича, а значение, для которого в грамматике запроса просто нет пути. Диспетчер grant type на token endpoint распознает ровно authorization_code, refresh_token и client_credentials; password по той же структурной причине уходит в unsupported_grant_type.

Ротация refresh token удаляет предыдущий токен и вставляет его замену в одной транзакции базы данных, а строка замены ссылается на токен, из которого она возникла, через колонку с ограничением UNIQUE. Если один и тот же refresh token предъявляют дважды (именно этот случай ротация и должна ловить), вставка во второй транзакции сталкивается с первой на этом ограничении и падает атомарно: окна, в котором гонка порождает два валидных токена-потомка, не существует. Клиенту в ответ сейчас уходит обычный invalid_grant, неотличимый от истекшего или неизвестного токена: повтор он останавливает, но пока не позволяет вызывающей стороне отличить по одному ответу API «этот токен использовали повторно» от «такого токена не существует». Это реальный разрыв между тем, что механизм ротации умеет обнаруживать внутри, и тем, что он сейчас показывает наружу, и мы планируем его закрыть — заставив повторное использование отзывать всю сессию, а не просто молча отклонять запрос.

Каждый endpoint, принимающий bearer-токен (/userinfo, интроспекция токена), читает его исключительно из заголовка Authorization; пути, который заодно проверял бы query-параметр, нет и отключать нечего.

← Все статьи