OAuth 2.0 и OpenID Connect: потоки, PKCE, типичные ошибки внедрения
В середине 2000-х, чтобы сервис печати фотографий забрал ваши снимки с фотохостинга, вы вводили в его форму логин и пароль от фотохостинга. Это работало и было катастрофой по всем осям сразу: сервис получал полный доступ вместо доступа к одной папке, получал его навсегда, хранил ваш пароль в своей базе, и отозвать этот доступ можно было только сменой пароля — вместе со всеми остальными интеграциями. Проблема называется делегирование доступа, и OAuth появился как её решение: вместо «отдать ключ от квартиры» — «выдать ограниченный, срочный и отзываемый пропуск на конкретную дверь».
OAuth 2.0 (RFC 6749, 2012) — это фреймворк делегированной авторизации. Не протокол аутентификации. Не «вход через соцсеть». Именно фреймворк: он описывает роли, потоки и параметры, но оставляет десятки решений на усмотрение внедряющего. Отсюда главное свойство OAuth с точки зрения безопасности: его почти никогда не ломают криптографией — его ломают ошибками внедрения. Нестрогое сравнение redirect_uri, потерянный state, ID-токен, который «и так пришёл от провайдера, чего его проверять», access-токен, принятый за удостоверение личности. Каждая из этих ошибок — одна строка кода, и каждая даёт полный захват аккаунта.
OpenID Connect (OIDC Core 1.0, 2014) — тонкий, но строго определённый слой поверх OAuth 2.0, который отвечает на вопрос «кто пользователь». Он добавляет ID-токен, nonce, /userinfo, discovery и правила валидации. Если вам нужен вход, вам нужен OIDC, а не «OAuth плюс запрос профиля».
Статья идёт по знакомой схеме: как выглядит уязвимый код → почему это работает → как чинить правильно (с кодом) → как проверить, что починено. Предполагается, что вы прошли моделирование угроз и аутентификацию — здесь мы не про пароли, а про то, что происходит после того, как провайдер их проверил.
Правила игры
Всё описанное применимо только к системам, которыми вы владеете, или к тем, на тестирование которых у вас есть письменное разрешение владельца — договор с зафиксированным scope, окнами и rules of engagement. Проверка чужого сервера авторизации «просто посмотреть, принимает ли он мой redirect_uri» — не исследование, а несанкционированный доступ. Для практики поднимайте у себя локально: Keycloak или Ory Hydra как сервер авторизации, OWASP Juice Shop и PortSwigger Web Security Academy как учебные полигоны. Проверять свою реализацию на соответствие спецификации можно conformance suite от OpenID Foundation — он гоняет по вашему стенду сотни негативных кейсов.
Роли, токены и главный водораздел
Три разных вопроса, которые постоянно путают:
| Вопрос | Что отвечает | Артефакт |
|---|---|---|
| Кто этот пользователь? | OpenID Connect | id_token |
| Что этой программе разрешено делать от его имени? | OAuth 2.0 | access_token + scope |
| Что этому пользователю вообще можно в моей системе? | Ваша авторизация | ваши роли и правила |
Третья строка — не про OAuth вообще. scope — это то, что клиент попросил и пользователь разрешил, а не то, что пользователю можно. Права проверяются у вас, по вашей модели — см. авторизацию.
Роли RFC 6749:
- Resource Owner — пользователь, владелец данных;
- Client — приложение, которое хочет доступ к данным от его имени (в OIDC — Relying Party, RP);
- Authorization Server (AS) — выдаёт токены, аутентифицирует пользователя, спрашивает согласие;
- Resource Server (RS) — API, которое принимает access-токен и отдаёт данные;
- User Agent — браузер, через который идёт front-channel.
Клиенты делятся на конфиденциальные (умеют хранить секрет: серверный бэкенд) и публичные (не умеют: SPA, мобильное приложение, CLI). Секрет, зашитый в APK или в JS-бандл, — это не секрет, а строка, которую вытащит любой за десять минут; такой клиент публичный по факту, как бы вы его ни зарегистрировали.
Три токена и их адресаты — это тот водораздел, из которого растёт половина ошибок в этой статье:
access_tokenпредназначен для RS. Для клиента он непрозрачен: клиент не должен его парсить, даже если это JWT, и не должен делать из него выводы о пользователе.id_tokenпредназначен для клиента. Он говорит клиенту, кто вошёл. Отправлять его в API вместо access-токена — ошибка: у него другая аудитория и другой смысл.refresh_tokenпредназначен для AS и только для него. Это долгоживущий секрет, самый ценный из трёх.
Устройство самих JWT, подпись, срок жизни и отзыв разбираются в следующей статье трека; здесь нас интересует протокол вокруг них.
Карта спецификаций: как стандарт учился на ошибках
OAuth 2.0 — не один документ, а семейство. Читать его полезно как хронику разбора инцидентов: почти каждый RFC после 2012 года закрывает конкретный класс ошибок внедрения.
Практический вывод: отправной точкой сегодня является не RFC 6749, а RFC 9700 — Best Current Practice. Он прямо запрещает то, что RFC 6749 разрешал, и его требования перенесены в черновик OAuth 2.1, который консолидирует всё в один документ. Ключевые изменения относительно «классического» OAuth 2.0:
- PKCE обязателен для всех клиентов, использующих authorization code, включая конфиденциальные.
redirect_uriсравнивается точным посимвольным сравнением; никаких шаблонов и префиксов.- Implicit grant (
response_type=token) удалён. - Resource Owner Password Credentials grant (
grant_type=password) удалён. - Bearer-токены нельзя передавать в query-строке URL.
- Refresh-токены обязаны быть либо sender-constrained, либо одноразовыми с ротацией.
Если ваша реализация не соответствует этому списку — у вас не «старый OAuth», у вас долг по безопасности с известным сроком давности.
Authorization code + PKCE: единственный поток, который вам нужен
Начнём с картины каналов, потому что без неё нельзя понять ни один из последующих дефектов. У OAuth два принципиально разных канала связи, и у них разные свойства безопасности.
Front-channel — это редиректы через браузер пользователя. Всё, что там едет, попадает в адресную строку, историю браузера, заголовок Referer при переходе со страницы /callback, логи обратного прокси, CDN и корпоративного TLS-инспектора, а также к любому браузерному расширению с правами на чтение вкладок. Front-channel не конфиденциален и не аутентифицирован: клиент не может доказать, что редирект пришёл именно от того AS, а AS не может доказать, что запрос инициировал именно этот клиент.
Back-channel — прямой HTTPS-запрос от бэкенда клиента к AS. Он конфиденциален (TLS до конкретного хоста, см. транспортную безопасность) и аутентифицирован в обе стороны, если клиент конфиденциальный.
Весь дизайн authorization code flow сводится к одному инженерному решению: через ненадёжный канал едет только одноразовый бесполезный сам по себе идентификатор (код), а всё ценное — через надёжный.
кладёт их в СЕРВЕРНУЮ сессию с TTL C-->>B: 302 на authorization_endpoint
с code_challenge=S256(verifier), state, nonce B->>AS: GET /authorize AS->>U: аутентификация плюс экран согласия U->>AS: подтверждает AS-->>B: 302 на redirect_uri с code, state, iss B->>C: GET /callback с code, state, iss Note over C: сверяет state, сверяет iss,
достаёт verifier из сессии C->>AS: POST /token с code, code_verifier,
redirect_uri и аутентификацией клиента AS->>AS: SHA-256 verifier сравнивает с сохранённым challenge AS-->>C: access_token, id_token, refresh_token Note over C: валидирует id_token — подпись, iss, aud, exp, nonce C-->>B: Set-Cookie httpOnly Secure SameSite, сессия приложения C->>RS: запрос с Authorization Bearer access_token RS->>RS: проверяет подпись, iss, aud, exp, scope RS-->>C: данные
Разберём параметры запроса авторизации по одному — каждый из них закрывает конкретную угрозу:
| Параметр | Зачем | Что ломается без него |
|---|---|---|
response_type=code |
просим код, а не токен | implicit отдаёт токен во front-channel |
client_id |
идентификация клиента | — |
redirect_uri |
куда вернуть код | несовпадение с зарегистрированным = утечка кода |
scope |
запрашиваемые права | избыточные права у клиента |
state |
привязка ответа к сессии браузера | CSRF на процесс входа |
nonce |
привязка ID-токена к этому запросу | replay ранее выданного ID-токена |
code_challenge + _method=S256 |
привязка кода к экземпляру клиента | перехват и инъекция кода |
resource / audience |
для какого API нужен токен (RFC 8707) | токен «на всё», confused deputy |
state и nonce — не взаимозаменяемы и не заменяются PKCE:
stateзащищает клиента от того, что в его callback придёт чужой ответ (CSRF на вход);nonceзащищает клиента от повторного использования ранее выданногоid_token;code_challengeзащищает код от использования кем-то, кроме того экземпляра клиента, который начинал поток.
Три разных актива, три разных механизма. Экономия на любом из них — не упрощение, а дефект.
PKCE изнутри
RFC 7636 описан в двух абзацах, и это, пожалуй, лучший показатель отношения «польза/сложность» во всём семействе.
import base64
import hashlib
import os
def _b64url(raw: bytes) -> str:
"""base64url без выравнивающих '=' — как требует RFC 7636 §4.1."""
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def make_pkce() -> tuple[str, str]:
# 32 байта из CSPRNG дают 43 символа — минимально допустимая длина verifier.
verifier = _b64url(os.urandom(32))
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
Свойства, которые важны:
code_verifierникогда не покидает клиента до момента обмена кода и уходит только по back-channel;code_challengeуходит во front-channel, но по нему нельзя восстановить verifier — SHA-256 односторонняя;- AS запоминает challenge вместе с кодом и на
/tokenпересчитываетSHA-256(verifier); не сходится — код аннулируется.
Метод plain (code_challenge = code_verifier) существует только для устройств, физически неспособных посчитать SHA-256, и не даёт никакой защиты, если канал скомпрометирован: verifier едет открытым текстом. RFC 9700 запрещает его для новых внедрений.
Отдельно про частое заблуждение: «PKCE нужен только публичным клиентам, у нас есть client_secret». Нет. Секрет клиента защищает от того, что чужое приложение обменяет код. Он не защищает от инъекции кода в ваш же поток: если противник получил код жертвы (утечка через Referer, лог прокси, открытый редирект, вредоносное расширение) и подставил его в свой собственный незавершённый сеанс с вашим клиентом, ваш клиент честно обменяет чужой код своим честным секретом — и выдаст противнику сессию жертвы. PKCE это ломает: verifier противника не соответствует challenge, который был сохранён вместе с кодом жертвы. Именно поэтому RFC 9700 §2.1.1 требует PKCE для всех.
Какой поток выбирать
который даёт согласие?"} Q0 -->|"нет, сервис ходит от себя"| CC["client_credentials
RFC 6749 §4.4
токен без sub, только client_id"] Q0 -->|"да"| Q1{"У устройства есть браузер
и удобный ввод?"} Q1 -->|"нет: ТВ, консоль, IoT, CLI"| DEV["Device Authorization Grant
RFC 8628 плюс PKCE"] Q1 -->|"да"| Q2{"Где выполняется код клиента?"} Q2 -->|"на сервере"| SRV["authorization code plus PKCE
конфиденциальный клиент"] Q2 -->|"SPA в браузере"| BFF["BFF: код меняет ваш бэкенд,
браузер получает httpOnly-сессию"] Q2 -->|"мобильное или desktop"| NAT["authorization code plus PKCE
системный браузер, RFC 8252"] Q3{"Нужно передать право
другому сервису вглубь?"} SRV --> Q3 Q3 -->|"да"| TE["Token Exchange
RFC 8693, сужаем audience и scope"] Q3 -->|"нет"| DONE["Готово"] DEAD1["implicit — удалён в OAuth 2.1"] DEAD2["password grant — удалён в OAuth 2.1"] DEAD1 -.->|"мигрировать на"| SRV DEAD2 -.->|"мигрировать на"| SRV
Почему два потока признаны негодными — это стоит понимать, а не заучивать.
Implicit (response_type=token) отдавал access-токен прямо в фрагменте URL редиректа. Токен оказывался в истории браузера, в Referer, в логах, был доступен любому скрипту на странице, не имел механизма привязки к клиенту и не мог сопровождаться refresh-токеном (поэтому его делали долгоживущим — вторая ошибка поверх первой). Придуман он был из-за отсутствия CORS: в 2012 году SPA физически не мог сделать POST на чужой домен. Сегодня CORS есть, а implicit — нет: RFC 9700 §2.1.2 запрещает выдачу токенов во front-channel.
ROPC (grant_type=password) заставлял клиента собирать пароль пользователя — ровно ту проблему, ради которой OAuth и создавали. Он ломает MFA, федерацию, риск-скоринг и captcha на стороне AS, приучает пользователя вводить пароль в чужие формы и требует, чтобы каждый клиент был доверенным хранителем паролей. RFC 9700 §2.4 запрещает его.
Device grant жив и полезен, но у него своя угроза: пользователю можно показать чужой user_code и убедить его подтвердить чужую сессию. Защита — короткий TTL кода (минуты), rate limit на /device_authorization, явное отображение имени клиента и запрашиваемых прав на экране подтверждения и, для чувствительных операций, отказ от verification_uri_complete в пользу ручного ввода кода.
client_credentials — это machine-to-machine, там нет пользователя вообще. Выданный токен представляет сервис, а не человека; класть в него sub пользователя нельзя. Секрет такого клиента — обычный секрет со всеми вытекающими требованиями к хранению и ротации, см. управление секретами. Лучше private_key_jwt (RFC 7523) или mTLS (RFC 8705) вместо разделяемой строки.
Двенадцать ошибок внедрения
Дальше — основная часть. Порядок примерно соответствует частоте, с которой эти дефекты встречаются на код-ревью.
1. Нестрогое сравнение redirect_uri
Как выглядит уязвимый код (на стороне сервера авторизации или прокси):
# УЯЗВИМО. Разбор механики, не для копирования.
def is_allowed_redirect(client, uri: str) -> bool:
return uri.startswith(client.base_redirect) # префиксное сравнение
Варианты того же дефекта: сравнение по регулярке с непроэкранированной точкой, шаблон https://*.example.com/*, сравнение только хоста, разрешение произвольного пути или произвольного query внутри «своего» домена.
Почему это работает. Префикс https://app.example.com/cb совпадает с https://app.example.com/cb.evil.test/, https://app.example.com/cb/../../open-redirect?to=... и https://app.example.com/cb?next=//attacker. Любое такое совпадение превращает redirect_uri в управляемый параметр — а по этому адресу AS отправит код авторизации. Родственный дефект: зарегистрированный адрес сам по себе является открытым редиректом (CWE-601) или содержит страницу, которая утекает URL через Referer, аналитику или Sentry.
Как чинить.
def is_allowed_redirect(client, uri: str) -> bool:
# Точное посимвольное сравнение с заранее зарегистрированным списком.
# RFC 9700 §2.1: simple string comparison, никакой нормализации.
return uri in client.registered_redirect_uris
И дополнительно:
- на каждый environment — свой клиент со своим списком адресов; никаких
localhostв проде; redirect_uriобязателен в запросе, даже если у клиента зарегистрирован один адрес, и обязан быть повторён в запросе к/token— AS сверяет оба;- ни одна страница по зарегистрированному адресу не должна выполнять редирект по параметру;
Referrer-Policy: no-referrerна странице callback, чтобы код не утёк во внешние ресурсы;- сразу после обработки — редирект на чистый URL, чтобы код не оставался в истории;
- если нужно вернуть пользователя на исходную страницу — храните её в серверной сессии рядом со
state, а не вredirect_uri.
Как проверить. Автотест на своём AS: перебор мутаций зарегистрированного адреса (лишний слэш, .., добавленный поддомен, смена схемы, добавленный порт, @, unicode-омоглифы, добавленный query, uppercase хост) — все должны получить invalid_request, ни одна не должна привести к 302 с кодом.
2. Отсутствующий или неправильно проверяемый state
Как выглядит уязвимый код:
# УЯЗВИМО
@app.get("/oauth/callback")
def callback():
code = request.args["code"] # state вообще не используется
tokens = exchange(code)
login_user(tokens)
Или так: state кладут в cookie без httpOnly, генерируют через random.random(), сравнивают через == вместо постоянного времени, или кладут в сам state полезную нагрузку (return_to=...) и потом ей доверяют.
Почему это работает. Без state любой может заставить браузер жертвы открыть ваш /callback с кодом, полученным в своём аккаунте у провайдера. Ваш клиент обменяет чужой код и привяжет к сессии жертвы личность противника — а дальше жертва «в своём» аккаунте загружает документы, привязывает карту и переписывается, а читает это владелец аккаунта. Это login CSRF, и он незаметен для пользователя.
Как чинить.
state = secrets.token_urlsafe(32) # CSPRNG, >= 128 бит энтропии
session["oauth"] = {"state": state, "ts": time.time(), "return_to": "/dashboard"}
# ...в callback:
pending = session.pop("oauth", None) # pop: state одноразовый
if not pending or time.time() - pending["ts"] > 600:
abort(400)
if not secrets.compare_digest(request.args.get("state", ""), pending["state"]):
abort(400)
return_to лежит в серверной сессии, а не в state, и всё равно проверяется на принадлежность вашему приложению перед редиректом. Если серверной сессии нет (stateless-клиент) — подписанная cookie с httpOnly, Secure, SameSite=Lax и коротким сроком, где лежит хеш state и verifier.
Как проверить. Тест: callback без state → 400; callback с валидным по формату, но чужим state → 400; повторный вызов callback с тем же state → 400; callback спустя 11 минут → 400.
3. PKCE «есть», но не работает
Три способа сломать PKCE, не убирая его из кода:
# УЯЗВИМО, вариант а: verifier генерируется в callback заново
verifier = make_verifier() # он обязан быть ТОТ ЖЕ, что и в /authorize
# УЯЗВИМО, вариант б: challenge = verifier, метод plain
params["code_challenge"] = verifier
params["code_challenge_method"] = "plain"
# УЯЗВИМО, вариант в: verifier кладут в cookie, доступную JS, или в localStorage
Плюс дефекты на стороне AS: не сохранять code_challenge вместе с кодом; не требовать code_verifier на /token, если challenge был; принимать plain, когда клиент прислал S256 (PKCE downgrade).
Почему это работает. PKCE — это доказательство владения секретом. Если секрет доступен противнику (localStorage при XSS, plain в URL) или AS его не проверяет, доказательство пустое, и мы возвращаемся к «кто предъявил код, тот и получил токен».
Как чинить. На клиенте: verifier живёт в серверной сессии/httpOnly-cookie, метод только S256, перед стартом потока проверяем метаданные:
methods = metadata().get("code_challenge_methods_supported", [])
if "S256" not in methods:
raise RuntimeError("AS не поддерживает S256 — нельзя начинать поток")
На стороне AS: если в /authorize был code_challenge, то /token обязан получить code_verifier, иначе invalid_grant; метод берётся из сохранённой записи, а не из запроса на обмен; код одноразовый и аннулируется при первой же неудачной попытке обмена.
Как проверить. Обмен кода без code_verifier → invalid_grant. Обмен с чужим verifier → invalid_grant. Повторный обмен того же кода → invalid_grant плюс отзыв ранее выданных по нему токенов (RFC 6749 §4.1.2 прямо это рекомендует).
4. ID-токен принимается на веру
Как выглядит уязвимый код:
# УЯЗВИМО. Классика: «токен же от провайдера, зачем проверять».
import jwt
claims = jwt.decode(id_token, options={"verify_signature": False})
user = User.get_or_create(email=claims["email"])
Более тонкие варианты: подпись проверяется, но algorithms берётся из заголовка токена; iss и aud не проверяются; nonce не сверяется; jwks_uri берётся из discovery-документа, полученного по адресу из недоверенного ввода.
Почему это работает. Механика разобрана в статье про JWT: доверять незаверенному JSON — то же самое, что доверять пользовательскому вводу. Без проверки aud вы принимаете токен, выписанный другому клиенту; без iss — токен от другого провайдера; без nonce — старый, перехваченный ранее токен.
Как чинить — полная валидация, где список алгоритмов зафиксирован кодом:
import secrets
import jwt
from jwt import PyJWKClient
_jwks = PyJWKClient(metadata()["jwks_uri"], cache_keys=True, lifespan=3600)
def verify_id_token(raw: str, expected_nonce: str) -> dict:
signing_key = _jwks.get_signing_key_from_jwt(raw) # kid → ключ из JWKS AS
claims = jwt.decode(
raw,
signing_key.key,
algorithms=["RS256", "ES256"], # фиксируем МЫ, а не заголовок токена
audience=CLIENT_ID, # aud обязан содержать наш client_id
issuer=ISSUER, # iss обязан совпасть посимвольно
leeway=60, # допуск на рассинхрон часов
options={"require": ["iss", "sub", "aud", "exp", "iat"]},
)
if not secrets.compare_digest(claims.get("nonce", ""), expected_nonce):
raise ValueError("nonce не совпал — возможен replay ID-токена")
if "azp" in claims and claims["azp"] != CLIENT_ID:
raise ValueError("azp указывает на другого клиента")
return claims
Дополнительно: jwks_uri берётся только из discovery-документа, полученного по адресу {ISSUER}/.well-known/openid-configuration, и поле issuer в этом документе обязано совпасть с ожидаемым ISSUER (OIDC Discovery §4.3) — иначе подмена discovery подменяет и набор ключей. Кэш JWKS обязан уметь обновляться при неизвестном kid, но с rate limit, иначе неизвестный kid становится усилителем запросов к AS.
Как проверить. Негативные тесты с самодельными токенами на стенде: alg: none; подпись чужим ключом; aud другого клиента; iss другого провайдера; exp в прошлом; отсутствующий или чужой nonce; корректный токен, но с kid, которого нет в JWKS. Все — отказ, каждый со своим кодом ошибки в логах.
5. Access-токен принят за удостоверение личности
Это самая дорогая ошибка в списке, потому что она выглядит как рабочая фича.
Как выглядит уязвимый код:
# УЯЗВИМО. Мобильное приложение прислало нам access_token, а мы «проверили» его,
# сходив за профилем.
@app.post("/api/login-with-social")
def login_with_social():
token = request.json["access_token"]
profile = httpx.get(PROVIDER_USERINFO, headers={"Authorization": f"Bearer {token}"}).json()
return issue_session(User.by_provider_id(profile["sub"]))
Почему это работает. Access-токен — это bearer-предъявительский пропуск для API провайдера, а не утверждение «предъявитель — это пользователь X, и он хотел войти именно к вам». В нём нет вашей аудитории. Любое приложение, которому пользователь когда-либо дал доступ, держит на руках валидный токен от его имени.
выданный НЕ вашему клиенту M->>APP: POST /api/login-with-social с этим токеном APP->>AS: GET /userinfo с полученным токеном AS-->>APP: sub, имя, e-mail — всё настоящее Note over APP: токен валиден, профиль настоящий,
вывод «это вошёл V» — НЕВЕРЕН APP-->>M: сессия пользователя V
Это учебный пример confused deputy: ваш сервер добросовестно выполняет проверку, которая ничего не доказывает. Именно поэтому OIDC ввёл отдельный токен с полем aud.
Как чинить. Три допустимых варианта, в порядке предпочтения:
- Не принимать токены от клиента вообще. Ваш бэкенд сам проводит authorization code + PKCE и сам получает
id_token. Мобильное приложение отдаёт вам не токен, а код авторизации, выписанный на вашclient_id. - Если принимаете
id_token— валидировать его полностью (пункт 4), иaudобязан равняться вашемуclient_id. - Если провайдер не даёт OIDC — только introspection (RFC 7662) с проверкой поля
client_id/audв ответе: токен должен быть выписан вашему клиенту. Просто «active: true» — недостаточно.
Как проверить. Тест: отправить в /api/login-with-social валидный токен, выписанный другому client_id на том же стенде провайдера, → 401.
6. Resource server не проверяет aud, iss и scope
Как выглядит уязвимый код:
// УЯЗВИМО: подпись проверена, смысл — нет.
tok, err := jwt.Parse(raw, keyfunc)
if err != nil || !tok.Valid {
http.Error(w, "unauthorized", 401)
return
}
// ...дальше работаем от имени tok.Claims["sub"]
Почему это работает. Валидная подпись означает только «это выдал наш AS». Она не означает «это выдано для меня», «это ещё действует» и «клиенту разрешено именно это». Сервис A принимает токен, выписанный для сервиса B, и внутренний сервис с низким доверием получает доступ к платёжному API.
Как чинить — полный набор проверок на входе в каждый сервис:
package api
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
type accessClaims struct {
Scope string `json:"scope"`
ClientID string `json:"client_id"`
jwt.RegisteredClaims
}
// resourceID — идентификатор ИМЕННО этого API, зарегистрированный на AS.
func NewVerifier(ctx context.Context, jwksURI, issuer, resourceID string) (func(string, string) (*accessClaims, error), error) {
kf, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURI}) // кэш и ротация ключей внутри
if err != nil {
return nil, err
}
return func(raw, requiredScope string) (*accessClaims, error) {
var c accessClaims
tok, err := jwt.ParseWithClaims(raw, &c, kf.Keyfunc,
jwt.WithValidMethods([]string{"RS256", "ES256"}), // алгоритмы фиксируем мы
jwt.WithIssuer(issuer), // iss
jwt.WithAudience(resourceID), // aud — это МЫ
jwt.WithExpirationRequired(), // exp обязателен
jwt.WithLeeway(30*time.Second),
)
if err != nil || !tok.Valid {
return nil, fmt.Errorf("токен отклонён: %w", err)
}
// RFC 9068: у access-токена свой media type, чтобы его нельзя было
// подсунуть туда, где ждут id_token, и наоборот.
if typ, _ := tok.Header["typ"].(string); !strings.EqualFold(typ, "at+jwt") {
return nil, fmt.Errorf("ожидался at+jwt, получен %q", typ)
}
if !slices.Contains(strings.Fields(c.Scope), requiredScope) {
return nil, fmt.Errorf("нет scope %q", requiredScope)
}
return &c, nil
}, nil
}
И главное, что не видно в коде: прохождение этой проверки не является авторизацией. scope=orders.read означает «клиенту разрешено читать заказы от имени пользователя», а не «этот пользователь может читать этот заказ». Проверка принадлежности объекта — отдельный слой, иначе получаем IDOR, см. безопасность API.
Как проверить. Тесты: токен с aud соседнего сервиса → 401; токен с истёкшим exp → 401; токен с typ: JWT вместо at+jwt → 401; валидный токен без нужного scope → 403; валидный токен с нужным scope, но чужой объект → 404/403.
7. Идентификация пользователя по e-mail вместо пары iss + sub
Как выглядит уязвимый код:
# УЯЗВИМО: аккаунты склеиваются по e-mail из ID-токена.
user = User.query.filter_by(email=claims["email"]).first() or User(email=claims["email"])
Почему это работает. email в ID-токене — изменяемый атрибут, и у многих провайдеров он может быть не подтверждён (email_verified: false) или переиспользован после удаления аккаунта. Если ваш сервис молча связывает внешний аккаунт с существующим локальным по совпадению e-mail, то регистрация в провайдере с чужим адресом даёт вход в чужой локальный аккаунт. Обратная ошибка — если провайдер разрешает смену e-mail, ваш пользователь после смены «теряет» аккаунт.
Как чинить. Единственный стабильный идентификатор — пара (iss, sub) (OIDC Core §2: sub уникален и неизменен в пределах issuer, но не глобально).
key = (claims["iss"], claims["sub"])
identity = ExternalIdentity.get(key)
if identity is None:
if not claims.get("email_verified"):
raise NeedsManualLink("провайдер не подтвердил e-mail")
# Автосвязка допустима только с подтверждённым адресом И подтверждением
# со стороны уже вошедшего пользователя — иначе создаём новый аккаунт.
identity = link_with_confirmation(key, claims["email"])
Привязку второго провайдера к существующему аккаунту делайте только из авторизованной сессии, с повторной аутентификацией — это чувствительная операция того же класса, что смена пароля.
Как проверить. Тест: два разных iss с одинаковым sub дают два разных локальных аккаунта; ID-токен с email_verified: false не приводит к автосвязке; смена email в ID-токене не меняет владельца аккаунта.
8. Токены живут в браузере там, где их достанет XSS
Почему это важно. Любой токен в localStorage — это секрет, доступный document-скрипту. Один успешный XSS (см. XSS и CSRF) превращается из «выполнить действия в текущей вкладке» в «унести refresh-токен и работать от имени пользователя неделями, из другой страны, без его сессии».
Как чинить. Рабочая рекомендация IETF OAuth WG (draft-ietf-oauth-browser-based-apps) — паттерн BFF: authorization code + PKCE выполняет ваш бэкенд, токены не покидают сервер, браузер получает обычную сессионную cookie.
// bff.ts — фрагмент. Браузер не видит ни одного токена OAuth.
app.get("/bff/callback", async (req, res) => {
const pending = await sessions.take(req.cookies["__Host-flow"]); // одноразово
assertState(req.query.state, pending.state);
assertIssuer(req.query.iss, ISSUER);
const tokens = await exchangeCode(String(req.query.code), pending.verifier);
await verifyIdToken(tokens.id_token, pending.nonce);
// Токены кладём в СЕРВЕРНОЕ хранилище сессий (Redis), в браузер — только id сессии.
const sid = await sessions.create({ ...tokens, sub: pending.sub });
res.cookie("__Host-sid", sid, {
httpOnly: true, // JS не прочитает
secure: true, // только по TLS
sameSite: "lax", // cross-site POST не принесёт куку
path: "/", // требование префикса __Host-
maxAge: 8 * 3600_000,
});
res.redirect(safeReturnTo(pending.returnTo));
});
// Все вызовы API идут через BFF: он подставляет Bearer сам.
app.use("/bff/api", requireSession, csrfProtect, proxyWithAccessToken);
Если BFF невозможен — access-токен только в памяти JS (переменная в замыкании, не в window, не в localStorage), refresh-токен в __Host--cookie с httpOnly, Secure, SameSite=Strict, обязательная ротация, и понимание, что XSS всё равно сможет использовать токен, пока страница жива.
Как проверить. Тест в браузере на своём стенде: после входа localStorage/sessionStorage пусты; document.cookie не содержит токенов; CSP не позволяет инлайн-скрипт; выход из системы делает старую cookie нерабочей на сервере, а не только удаляет её из браузера.
9. Refresh-токены без ротации и без обнаружения повторного использования
Как выглядит уязвимый код: /token с grant_type=refresh_token выдаёт новый access-токен, оставляя refresh-токен прежним и вечным.
Почему это плохо. Утёкший refresh-токен даёт бессрочный доступ, и вы об этом никогда не узнаете: легитимный пользователь и противник работают параллельно и одинаково успешно.
Как чинить — ротация плюс детектор повторного использования (RFC 9700 §4.14.2): каждый обмен выдаёт новый refresh-токен и помечает старый использованным; предъявление уже использованного токена означает, что копия есть у двоих, и вся «семья» токенов этой сессии отзывается.
Дополнительно: абсолютный предельный срок жизни семьи (например, 30 дней независимо от активности), привязка refresh-токена к клиенту, и для публичных клиентов — обязательная ротация, потому что альтернативной аутентификации у них нет. Ещё надёжнее — sender-constrained токены: DPoP (RFC 9449) или mTLS (RFC 8705), при которых украденный токен бесполезен без закрытого ключа.
Как проверить. Тест: обменять RT_1 → получить RT_2; повторно предъявить RT_1 → invalid_grant и RT_2 тоже перестал работать; в аудит-логе есть событие с идентификатором семьи.
10. Секрет в публичном клиенте и встроенный WebView
Симптомы: client_secret в JS-бандле, в strings.xml, в plist; вход в мобильном приложении открывается во встроенном WebView/SFSafariViewController с включённым доступом к DOM.
Почему это плохо. Секрет извлекается из артефакта тривиально — значит, любой может выдать себя за ваш клиент перед AS. Встроенный WebView, которым управляет само приложение, видит всё, что вводит пользователь на странице AS: это ровно та модель, от которой уходили, отказываясь от password grant. Пользователь при этом не может проверить адресную строку и сертификат.
Как чинить — RFC 8252:
- вход открывается в системном браузере (
ASWebAuthenticationSessionна iOS, Custom Tabs на Android) — приложение не имеет доступа к его содержимому; - клиент регистрируется как публичный, без секрета, PKCE обязателен;
- redirect: claimed HTTPS URI (Universal Links / App Links) предпочтительнее custom scheme, потому что произвольную схему может перехватить другое установленное приложение; для desktop/CLI — loopback
http://127.0.0.1:<случайный порт>, но неlocalhost; - никаких «универсальных» клиентов, общих для web и mobile: у каждого свой
client_idи свой набор redirect-адресов.
Как проверить. Ревью артефакта сборки на строки, похожие на секреты; проверка, что вход открывается вне процесса приложения; попытка зарегистрировать ваш custom scheme в тестовом приложении на своём устройстве.
11. Mix-up: клиент, работающий с несколькими провайдерами
Если ваш клиент поддерживает вход через несколько AS, callback должен уметь ответить на вопрос «а от кого, собственно, пришёл этот код?». Без ответа возможна путаница, в результате которой код, выданный одним провайдером, отправляется на /token другого — вместе с секретом клиента для этого другого.
Как чинить, любым из способов (лучше сразу двумя):
- Отдельный
redirect_uriна каждого провайдера:/callback/google,/callback/keycloak. Адрес однозначно определяет ожидаемый AS. - Параметр
issв ответе (RFC 9207): AS возвращает свой issuer, клиент сверяет его с тем, к которому обращался. Проверять надо строго: если запрос уходил к AS, который заявляет поддержкуiss, а параметр не пришёл — это ошибка, а не «ну, старый сервер». - Хранить в серверной сессии рядом со
stateидентификатор AS и братьtoken_endpointиз сохранённой записи, а не из свежего discovery по параметру запроса.
Как проверить. Тест: callback провайдера A с параметром iss провайдера B → 400; отсутствие iss от провайдера, объявившего authorization_response_iss_parameter_supported: true, → 400.
12. Выход, который ничего не выключает
Как выглядит уязвимый код: /logout удаляет cookie приложения — и всё.
Почему это плохо. Access-токены остаются валидными до exp, refresh-токен продолжает работать, сессия на стороне AS жива, и повторный вход проходит без запроса учётных данных. Для общего компьютера и для сценария «увели устройство» это означает, что выхода не произошло.
Как чинить — выход состоит из четырёх независимых действий:
- уничтожить серверную сессию приложения (не только cookie);
- отозвать refresh- и access-токены через
revocation_endpoint(RFC 7009); - при необходимости завершить сессию на AS — RP-Initiated Logout с
id_token_hintиpost_logout_redirect_uriиз списка зарегистрированных; - подписаться на back-channel logout (OIDC Back-Channel Logout 1.0): AS присылает вашему бэкенду
logout_tokenсeventsиsid, вы убиваете соответствующую сессию. Валидироватьlogout_tokenнадо так же строго, как ID-токен, и дополнительно проверять, что в нём нетnonce.
Держите access-токены короткими (5–15 минут) — при отзыве это ваш реальный worst case. Как строить отзыв для stateless-токенов, разбирается в следующей статье.
Как проверить. Тест: после выхода старый access-токен → 401 в пределах TTL + допуск; refresh-токен → invalid_grant; повторный переход на /authorize требует аутентификации, а не отдаёт код молча.
Что ещё стоит включить, когда база закрыта
- PAR, RFC 9126 — клиент отправляет параметры авторизации по back-channel и получает
request_uri; во front-channel остаются толькоclient_idи ссылка. Параметры нельзя ни подсмотреть, ни подменить, а AS аутентифицирует клиента ещё до редиректа. - JAR, RFC 9101 — подписанный объект запроса; целостность параметров подтверждена криптографически.
- DPoP, RFC 9449 — привязка токена к ключу клиента: каждый запрос сопровождается коротким proof-JWT с методом, URL, временем и
jti. Украденный токен без ключа бесполезен. - Rich Authorization Requests, RFC 9396 — вместо строкового
scopeструктурированное описание («перевод 500 RUB на счёт N»), что делает экран согласия осмысленным для финансовых операций. - FAPI 2.0 — профиль для финансовых API, который собирает всё перечисленное в обязательный набор. Хороший ориентир для «а что считается строгим».
- Step-up authentication, RFC 9470 — RS может потребовать более сильную аутентификацию (
acr_values,max_age) для конкретной операции.
Отдельно про российских провайдеров: Яндекс ID, VK ID, Сбер ID, ЕСИА и корпоративные IdP реализуют OAuth 2.0 и OIDC с разной степенью полноты. Не предполагайте наличие /.well-known/openid-configuration, поддержку PKCE, iss в ответе или back-channel logout — проверяйте по документации конкретного провайдера и закрывайте отсутствующие механизмы на своей стороне (например, раздельными redirect_uri вместо iss).
Как проверить, что починено
Регрессионные тесты на поток входа пишутся один раз и ловят возвраты дефектов навсегда. Минимальный набор для клиента:
import pytest
def test_callback_without_state_rejected(client, started_flow):
r = client.get("/oauth/callback?code=abc")
assert r.status_code == 400
def test_callback_with_foreign_state_rejected(client, started_flow):
r = client.get("/oauth/callback?code=abc&state=" + "x" * 43)
assert r.status_code == 400
def test_state_is_single_use(client, started_flow, as_stub):
ok = client.get(started_flow.callback_url)
assert ok.status_code == 302
replay = client.get(started_flow.callback_url) # тот же state и код
assert replay.status_code == 400
def test_id_token_alg_none_rejected(rp, as_stub):
bad = as_stub.forge_id_token(alg="none")
with pytest.raises(ValueError):
rp.verify_id_token(bad, expected_nonce=as_stub.last_nonce)
@pytest.mark.parametrize("field,value", [
("aud", "other-client"), ("iss", "https://evil.example"),
("exp", "past"), ("nonce", "mismatch"),
])
def test_id_token_claims_validated(rp, as_stub, field, value):
with pytest.raises(Exception):
rp.verify_id_token(as_stub.forge_id_token(**{field: value}),
expected_nonce=as_stub.last_nonce)
def test_token_request_carries_verifier(as_stub, started_flow):
body = as_stub.last_token_request
assert "code_verifier" in body
assert body["redirect_uri"] == REGISTERED_REDIRECT_URI
Для resource server — те же проверки на aud, iss, exp, typ и scope. Для стенда AS — прогон conformance suite OpenID Foundation и ручной перебор мутаций redirect_uri. Всё это ставится в CI рядом с SAST-правилами (Semgrep-правила на verify_signature: False, jwt.decode без audience, startswith рядом с redirect_uri) — см. безопасную разработку.
И общее правило: не пишите свой RP в проде руками. Код выше — учебный, он нужен, чтобы вы понимали, что делает библиотека и что проверить на ревью. В продакшне берите зрелые реализации: Authlib или oauthlib для Python, go-oidc для Go, openid-client для Node.js, Spring Security OAuth2 Client для JVM, MSAL/AppAuth для мобильных. Но конфигурацию проверяйте руками по чек-листу ниже: большинство библиотек умеют быть небезопасными, если их об этом попросить.
Мини-итог: чек-лист ревью
- Поток — только authorization code + PKCE (S256). Implicit и password grant отсутствуют.
redirect_uriсравнивается точным совпадением, зарегистрирован явно, повторён в запросе к/token, на странице callback нет открытого редиректа и естьReferrer-Policy: no-referrer.state— CSPRNG ≥128 бит, хранится на сервере, одноразовый, с TTL, сравнивается в постоянное время.nonceпередаётся и сверяется с тем, что в ID-токене.code_verifierживёт там же, гдеstate, и не доступен JS; AS требует его на/token.- ID-токен валидируется полностью: подпись ключом из JWKS по
iss, зафиксированный списокalgorithms,iss,aud,exp,iat,nonce,azp. - Пользователь идентифицируется парой
(iss, sub); автосвязка по e-mail — только приemail_verifiedи подтверждении. - Access-токены не принимаются как доказательство личности; RS проверяет
iss,aud,exp,typ=at+jwtиscope, а права на объект — отдельно. - Токены не хранятся в
localStorage; для браузерных приложений — BFF с__Host--cookie. - Refresh-токены ротируются, повторное использование отзывает семью, есть абсолютный лимит жизни.
- Публичные клиенты без секретов; мобильный вход — в системном браузере, RFC 8252.
- Мультипровайдерный клиент защищён
iss(RFC 9207) и/или раздельными redirect-адресами. - Выход отзывает токены и завершает сессию AS; подключён back-channel logout.
- Всё перечисленное закрыто автотестами, а конфигурация AS — прогоном conformance suite.
Источники
- RFC 6749 — The OAuth 2.0 Authorization Framework
- RFC 6750 — Bearer Token Usage
- RFC 6819 — OAuth 2.0 Threat Model and Security Considerations
- RFC 7636 — PKCE
- RFC 7662 — Token Introspection; RFC 7009 — Token Revocation
- RFC 8252 — OAuth 2.0 for Native Apps (BCP 212)
- RFC 8414 — Authorization Server Metadata
- RFC 8628 — Device Authorization Grant; RFC 8693 — Token Exchange
- RFC 8705 — mTLS Client Authentication and Certificate-Bound Tokens
- RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
- RFC 9101 — JAR; RFC 9126 — PAR
- RFC 9207 — Authorization Server Issuer Identification
- RFC 9396 — Rich Authorization Requests; RFC 9449 — DPoP
- RFC 9700 — Best Current Practice for OAuth 2.0 Security (BCP 240)
- OpenID Connect Core 1.0, Discovery 1.0, RP-Initiated Logout, Back-Channel Logout
- OAuth 2.1, draft-ietf-oauth-v2-1 и OAuth 2.0 for Browser-Based Applications
- OWASP Top 10 2021, A07: Identification and Authentication Failures и A01: Broken Access Control
- OWASP Cheat Sheet: OAuth 2.0 Protocol, JSON Web Token
- CWE: CWE-601 открытый редирект, CWE-352 CSRF, CWE-345 недостаточная проверка подлинности данных, CWE-347 неверная проверка подписи, CWE-613 недостатки истечения сессии
- NIST SP 800-63C — федерация и assertions; NIST SP 800-218 (SSDF)
- OpenID Foundation Certification and Conformance Suite
- Aaron Parecki, «OAuth 2.0 Simplified» и oauth.net — самый доступный вход в тему
- Justin Richer, Antonio Sanso, «OAuth 2 in Action», Manning — разбор потоков и атак с реализацией
Что дальше
Мы разобрали протокол вокруг токенов: кто их выдаёт, кому, по какому каналу и что проверяет получатель. Осталось разобрать сами токены — что внутри JWT, как устроена подпись и почему alg из заголовка нельзя использовать при проверке, как выбирать срок жизни, как строить отзыв для того, что задумано как stateless, и в каких случаях JWT просто не нужен и обычная серверная сессия лучше по всем параметрам.
JWT и токены: устройство, подпись, срок жизни, отзыв и когда JWT не нужен