Безопасность приложений OAuth 2.0 и OpenID Connect: потоки, PKCE, типичные ошибки внедрения
0%

OAuth 2.0 и OpenID Connect: потоки, PKCE, типичные ошибки внедрения

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:

  1. PKCE обязателен для всех клиентов, использующих authorization code, включая конфиденциальные.
  2. redirect_uri сравнивается точным посимвольным сравнением; никаких шаблонов и префиксов.
  3. Implicit grant (response_type=token) удалён.
  4. Resource Owner Password Credentials grant (grant_type=password) удалён.
  5. Bearer-токены нельзя передавать в query-строке URL.
  6. Refresh-токены обязаны быть либо sender-constrained, либо одноразовыми с ротацией.

Если ваша реализация не соответствует этому списку — у вас не «старый OAuth», у вас долг по безопасности с известным сроком давности.

Authorization code + PKCE: единственный поток, который вам нужен

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

Front-channel и back-channel в OAuth, связка PKCE

Front-channel — это редиректы через браузер пользователя. Всё, что там едет, попадает в адресную строку, историю браузера, заголовок Referer при переходе со страницы /callback, логи обратного прокси, CDN и корпоративного TLS-инспектора, а также к любому браузерному расширению с правами на чтение вкладок. Front-channel не конфиденциален и не аутентифицирован: клиент не может доказать, что редирект пришёл именно от того AS, а AS не может доказать, что запрос инициировал именно этот клиент.

Back-channel — прямой HTTPS-запрос от бэкенда клиента к AS. Он конфиденциален (TLS до конкретного хоста, см. транспортную безопасность) и аутентифицирован в обе стороны, если клиент конфиденциальный.

Весь дизайн authorization code flow сводится к одному инженерному решению: через ненадёжный канал едет только одноразовый бесполезный сам по себе идентификатор (код), а всё ценное — через надёжный.

Разберём параметры запроса авторизации по одному — каждый из них закрывает конкретную угрозу:

Параметр Зачем Что ломается без него
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 для всех.

Какой поток выбирать

Почему два потока признаны негодными — это стоит понимать, а не заучивать.

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_verifierinvalid_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, и он хотел войти именно к вам». В нём нет вашей аудитории. Любое приложение, которому пользователь когда-либо дал доступ, держит на руках валидный токен от его имени.

Это учебный пример confused deputy: ваш сервер добросовестно выполняет проверку, которая ничего не доказывает. Именно поэтому OIDC ввёл отдельный токен с полем aud.

Как чинить. Три допустимых варианта, в порядке предпочтения:

  1. Не принимать токены от клиента вообще. Ваш бэкенд сам проводит authorization code + PKCE и сам получает id_token. Мобильное приложение отдаёт вам не токен, а код авторизации, выписанный на ваш client_id.
  2. Если принимаете id_token — валидировать его полностью (пункт 4), и aud обязан равняться вашему client_id.
  3. Если провайдер не даёт 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

Три схемы хранения токенов в браузерном приложении и зона досягаемости 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 другого — вместе с секретом клиента для этого другого.

Как чинить, любым из способов (лучше сразу двумя):

  1. Отдельный redirect_uri на каждого провайдера: /callback/google, /callback/keycloak. Адрес однозначно определяет ожидаемый AS.
  2. Параметр iss в ответе (RFC 9207): AS возвращает свой issuer, клиент сверяет его с тем, к которому обращался. Проверять надо строго: если запрос уходил к AS, который заявляет поддержку iss, а параметр не пришёл — это ошибка, а не «ну, старый сервер».
  3. Хранить в серверной сессии рядом со 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 жива, и повторный вход проходит без запроса учётных данных. Для общего компьютера и для сценария «увели устройство» это означает, что выхода не произошло.

Как чинить — выход состоит из четырёх независимых действий:

  1. уничтожить серверную сессию приложения (не только cookie);
  2. отозвать refresh- и access-токены через revocation_endpoint (RFC 7009);
  3. при необходимости завершить сессию на AS — RP-Initiated Logout с id_token_hint и post_logout_redirect_uri из списка зарегистрированных;
  4. подписаться на 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 для мобильных. Но конфигурацию проверяйте руками по чек-листу ниже: большинство библиотек умеют быть небезопасными, если их об этом попросить.

Мини-итог: чек-лист ревью

  1. Поток — только authorization code + PKCE (S256). Implicit и password grant отсутствуют.
  2. redirect_uri сравнивается точным совпадением, зарегистрирован явно, повторён в запросе к /token, на странице callback нет открытого редиректа и есть Referrer-Policy: no-referrer.
  3. state — CSPRNG ≥128 бит, хранится на сервере, одноразовый, с TTL, сравнивается в постоянное время.
  4. nonce передаётся и сверяется с тем, что в ID-токене.
  5. code_verifier живёт там же, где state, и не доступен JS; AS требует его на /token.
  6. ID-токен валидируется полностью: подпись ключом из JWKS по iss, зафиксированный список algorithms, iss, aud, exp, iat, nonce, azp.
  7. Пользователь идентифицируется парой (iss, sub); автосвязка по e-mail — только при email_verified и подтверждении.
  8. Access-токены не принимаются как доказательство личности; RS проверяет iss, aud, exp, typ=at+jwt и scope, а права на объект — отдельно.
  9. Токены не хранятся в localStorage; для браузерных приложений — BFF с __Host--cookie.
  10. Refresh-токены ротируются, повторное использование отзывает семью, есть абсолютный лимит жизни.
  11. Публичные клиенты без секретов; мобильный вход — в системном браузере, RFC 8252.
  12. Мультипровайдерный клиент защищён iss (RFC 9207) и/или раздельными redirect-адресами.
  13. Выход отзывает токены и завершает сессию AS; подключён back-channel logout.
  14. Всё перечисленное закрыто автотестами, а конфигурация AS — прогоном conformance suite.

Источники

Что дальше

Мы разобрали протокол вокруг токенов: кто их выдаёт, кому, по какому каналу и что проверяет получатель. Осталось разобрать сами токены — что внутри JWT, как устроена подпись и почему alg из заголовка нельзя использовать при проверке, как выбирать срок жизни, как строить отзыв для того, что задумано как stateless, и в каких случаях JWT просто не нужен и обычная серверная сессия лучше по всем параметрам.

JWT и токены: устройство, подпись, срок жизни, отзыв и когда JWT не нужен

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов