open atlas
← Все проекты

backend · starter · 3d

Мини OAuth 2.0 + PKCE логин

Реализуй поток authorization-code + PKCE целиком против реального провайдера, чтобы понять каждый редирект и токен, а не доверять библиотеке.

Большинство разработчиков используют OAuth-библиотеку и никогда не смотрят внутрь. Этот проект — противоположность: ты реализуешь поток authorization-code + PKCE сам — каждый редирект, каждую CSRF-защиту, каждую проверку токена, — так что threat model — это не список для запоминания, а следствие написанного кода. PKCE означает, что украденный authorization code бесполезен без verifier, сгенерированного в той же браузерной сессии. Ротация refresh-токена означает, что утёкший refresh-токен самоуничтожается при первом реплее. Хранение сессии в httpOnly cookie означает, что XSS не может её украсть. Ничего магического; это ряд проектных решений, стоимость которых — ровно одно понимание.

Результат

Рабочий «Войти через X», который меняет код на токены через PKCE, проверяет state и безопасно хранит сессию.

Этапы

0/6 · 0%
  1. 01Построй authorization-эндпойнт и старт PKCE

    Подними минимальный authorization-сервер: эндпойнт /authorize, принимающий client_id, redirect_uri, response_type=code, scope и PKCE code_challenge (только S256 — plain отклоняй), и клиент, инициирующий поток. Authorization code — это одноразовый короткоживущий носитель намерения: держи его TTL жёстким (30–60 с, единственное использование), потому что он идёт через браузер и query-строку редиректа — самый открытый участок всего потока. PKCE привязывает будущий обмен токена к браузеру, который его начал: клиент генерирует code_verifier из 43–128 символов высокой энтропии (≥256 бит из CSPRNG), отправляет вперёд только его SHA-256 challenge и раскрывает verifier на шаге токена — так что перехваченный код бесполезен без verifier. Компромисс: PKCE не добавляет хранимого на сервере секрета (в отличие от секрета конфиденциального клиента) — именно поэтому он обязателен для публичных/нативных клиентов, которые секрет хранить не могут.

    Критерии готовности
    • /authorize валидирует redirect_uri по точному зарегистрированному allowlist и отклоняет любой незарегистрированный или wildcard-суффиксный URI.
    • Сервер принимает только code_challenge_method S256, а выданный authorization code одноразовый с TTL ≤ 60 с.
    • Клиент генерирует свежий code_verifier из CSPRNG (≥256 бит энтропии) на каждый поток и не переиспользует его.
    Самопроверка

    Покажи свою проверку redirect_uri; senior-ревьюер подтверждает, что это точный allowlist, а не проверка префикса/подстроки, которой может удовлетворить подконтрольный атакующему подпуть.

  2. 02Token-эндпойнт: подписывай access и refresh токены

    Построй эндпойнт /token, который меняет code + code_verifier на access-токен и refresh-токен. Проверь verifier (SHA-256(code_verifier) == сохранённый challenge) и атомарно погаси код, чтобы повторно отправленный код проваливался. Подписывай access-токены как JWT асимметричным ключом (RS256/ES256, не HS256 — ресурс-серверы должны проверять публичным ключом и никогда не получать секрет подписи), держи TTL access-токена коротким (5–15 мин), чтобы утёкший токен быстро истекал, а refresh-токены делай долгоживущими непрозрачными хендлами. Опубликуй JWKS-эндпойнт с kid в заголовке каждого токена, чтобы ротировать ключи подписи без простоя: вкати новый ключ, отдавай оба в JWKS в окне перекрытия (≥ максимального TTL токена), затем выведи старый kid. Компромисс: самодостаточные JWT (без обращения на introspection, но живой нельзя отозвать) против непрозрачных токенов (отзываемы, но каждая проверка — сетевой хоп).

    Критерии готовности
    • Обмен токена проверяет PKCE verifier и отклоняет повторно отправленный (уже погашенный) authorization code ошибкой invalid_grant.
    • Access-токены — JWT, подписанные RS256/ES256, несущие kid, exp, iss и aud, проверяемые против опубликованного JWKS.
    • TTL access-токена ≤ 15 мин, а refresh-токены хранятся в хешированном виде, никогда в открытом.
    Самопроверка

    Назови свой алгоритм и как верификатор отклоняет токен с alg:none или HS256-с-публичным-ключом; senior-ревьюер проверяет, что ты фиксируешь ожидаемый алгоритм, а не доверяешь заголовку.

  3. 03Подключи клиент: callback, state, валидация редиректа

    Соедини клиент relying-party с твоим сервером от и до и валидируй ID-токен до того, как доверять хоть одному claim. Обработчик callback должен сверить вернувшийся state со значением, сохранённым до редиректа (это CSRF-защита потока — без неё атакующий вошьёт свой код в сессию жертвы), а затем обменять код. Валидируй подпись ID-токена против JWKS, плюс iss, aud, exp и nonce — отсутствие проверки aud означает, что твоё приложение примет токен, выпущенный для другого клиента. Зафиксируй цель редиректа: возвращай браузер только на валидированный зарегистрированный URI, потому что небрежный параметр «return to» — это классический open-redirect, превращающий твой логин в стартовую площадку атакующего.

    Критерии готовности
    • Callback отклоняет несовпадающий или отсутствующий state жёсткой ошибкой и никогда не переходит к обмену токена.
    • У ID-токена проверяются подпись, iss, aud, exp и nonce; токен с неверным aud отклоняется.
    • Любая цель пост-логин-редиректа валидируется по зарегистрированному allowlist, так что подделанный «return to» не может сделать open-redirect пользователя.
    Самопроверка

    Пройди по тому, что происходит при отсутствии state на callback; senior-ревьюер проверяет, что запрос отклоняется, а не молча пропускается, потому что «код выглядел валидным».

  4. 04Скоупы, согласие, introspection/revocation

    Сделай так, чтобы авторизация что-то значила. Добавь шаг согласия, где владелец ресурса одобряет конкретные scope, запрошенные клиентом, и обеспечь least privilege: токен с read:profile не должен проходить проверку write:billing. Выдавай токены, несущие только согласованные scope, и добавь два эндпойнта RFC 7662/7009 — /introspect, чтобы ресурс-сервер мог спросить «этот непрозрачный токен ещё активен и какие у него scope?», и /revoke, чтобы пользователь или админ мог мгновенно убить токен. Это ответ на пробел отзыва JWT из milestone токена: короткоживущие access-JWT, которые ты даёшь истечь, плюс отзываемые refresh-токены и непрозрачные токены, которые можно интроспектировать. Компромисс — латентность: introspection добавляет сетевой вызов на запрос — поэтому кэшируй результат активного токена на несколько секунд и прими, что отзыв eventually-consistent в этом окне.

    Критерии готовности
    • Шаг согласия фиксирует одобренные scope, и выданный токен несёт ровно их, не шире.
    • /introspect возвращает active:false для истёкшего или отозванного токена, а ресурс-сервер отклоняет запрос, у токена которого нет нужного scope.
    • /revoke немедленно делает refresh-токен недействительным, так что он не может выпускать новые access-токены.
    Самопроверка

    Объясни, как ресурс-сервер проверяет scope непрозрачного токена; senior-ревьюер проверяет, что он обращается к introspection (или проверенному claim), а не доверяет заявленному клиентом scope.

  5. 05Ротация refresh и безопасность сессии

    Укрепи долгоживущую часть потока. Реализуй ротацию refresh-токена: каждый вызов refresh аннулирует старый токен и выдаёт новый, так что refresh-токен одноразовый. Выигрыш — детект повторного использования: если уже ротированный (погашенный) refresh-токен предъявлен снова, значит легитимный клиент или вор его реиграет, поэтому ты отзываешь всё семейство токенов и форсируешь повторную аутентификацию. Задай жёсткое абсолютное время жизни refresh-токена (например, 14–30 дней) и idle-окно, чтобы брошенные сессии умирали. На стороне браузера храни сессию в httpOnly, Secure, SameSite cookie (не в localStorage, который читает любой XSS) и узко её ограничь. Компромисс: ротация добавляет конкуренцию записи в хранилище refresh и гонку, когда две вкладки рефрешат разом — разрули гонку коротким grace-окном или single-flight-локом, а не сноси легитимное семейство.

    Критерии готовности
    • Каждый refresh выдаёт новый токен и аннулирует старый; предъявление погашенного refresh-токена отзывает всё семейство токенов.
    • Сессия браузера живёт в httpOnly + Secure + SameSite cookie, и ты можешь обосновать это против localStorage.
    • Одновременный двойной refresh из двух вкладок не вызывает ложного срабатывания детекта реюза и не отзывает валидную сессию.
    Самопроверка

    Опиши триггер детекта реюза и что он отзывает; senior-ревьюер проверяет, что он убивает семейство при реплее, но терпит безобидную гонку двух вкладок.

  6. 06Threat model, наблюдение и инцидент с утечкой токена

    Свяжи всё письменным threat model и телеметрией, чтобы по нему действовать, затем переживи инцидент. Перечисли поверхность атаки потока — перехват authorization-code, open redirect, CSRF через отсутствие state, утечка токена через логи или Referer и кража refresh-токена — и сопоставь каждому защиту, которую ты построил. Сними с auth-пути RED-метрики (rate, errors, duration на /authorize, /token, /introspect) и структурные логи, которые НИКОГДА не пишут сырые токены, code_verifier или секреты клиента — максимум хешированный id токена. Затем отыграй инцидент: утекает refresh-токен (скажем, он попал в серверный лог, ушедший третьей стороне). Обнаружь аномальный реюз по метрикам, отзови затронутое семейство токенов вживую и напиши короткий пост-мортем. Настоящий фикс — не просто «ротировать утёкший токен», а закрыть путь утечки (редакция, никаких токенов в URL) плюс ротация, чтобы будущая утечка самозалечивалась при следующем использовании.

    Критерии готовности
    • Письменный threat model перечисляет ≥5 атак (перехват кода, open redirect, CSRF, утечка токена в логе, кража refresh) — каждая сопоставлена конкретной защите в твоём коде.
    • RED-метрики покрывают auth-эндпойнты, а аудит логов доказывает, что сырой токен, verifier или секрет никогда не пишутся.
    • Ты воспроизвёл реюз утёкшего refresh-токена, отозвал семейство вживую, и пост-мортем называет путь утечки и превенцию (редакция + ротация), а не только «мы его ротировали».
    Самопроверка

    Вставь пункт превенции из пост-мортема; senior-ревьюер проверяет, что он закрывает путь утечки (редакция, никаких токенов в URL/логах) и опирается на ротацию, а не на разовый отзыв известно-утёкшего токена.

Рубрика

Джуниор Миддл Сеньор
Реализация PKCE и закалка authorization code Поток перенаправляет на провайдера и меняет код на токены, но не реализует PKCE или проверяет только формат code_challenge, не верифицируя его на шаге токена. Клиент генерирует пер-поточный code_verifier из CSPRNG (≥256 бит), отправляет вперёд только S256 challenge, а эндпоинт токена верифицирует SHA-256(verifier) == сохранённый challenge, атомарно погашая код. Сервер отклоняет plain code_challenge_method явной ошибкой (без фолбэка). Authorization code одноразовый с TTL ≤ 60 с; попытка реплея после погашения возвращает invalid_grant. redirect_uri валидируется по точному allowlist — не проверкой префикса, — и ты можешь показать, как проверка wildcard-суффикса открывает редирект атакующему.
Подпись токенов, фиксация алгоритма и ротация JWKS Access-токены подписаны HS256 общим секретом, или алгоритм читается из заголовка JWT при верификации без фиксации. Токены — JWT, подписанные RS256/ES256, несущие kid, exp, iss и aud; верификатор фиксирует ожидаемый алгоритм и отклоняет alg:none или атаку с подменой ключа; эндпоинт JWKS публикует публичный ключ для ресурс-серверов. Ротация ключа подписи работает без простоя: вводится новый kid, оба ключа появляются в JWKS в окне перекрытия ≥ максимального TTL access-токена, затем старый kid выводится, пока живые токены продолжают проверяться. Ты можешь назвать минимум перекрытия и объяснить, почему более короткое окно аннулировало бы ещё валидные токены.
Защита state/CSRF и безопасность хранения токенов Параметр state генерируется, но не проверяется в callback, или сессия хранится в localStorage, откуда её может прочитать любой XSS. Callback отклоняет отсутствующий или несовпадающий state жёсткой ошибкой до перехода к обмену токена; браузерная сессия живёт в httpOnly + Secure + SameSite cookie. Значение state — это nonce из CSPRNG, привязанный к браузерной сессии, так что CSRF-атакующий не может его предсказать или подделать. Refresh-токены хранятся в хешированном виде (не открытым текстом), чтобы дамп базы данных не давал живых refresh-учётных данных. Любая цель пост-логин-редиректа валидируется по зарегистрированному allowlist, закрывая поверхность атаки open-redirect, которая превратила бы твой поток входа в стартовую площадку атакующего.
Ротация refresh-токена и детект реплея Refresh-токены долгоживущие и многоразовые; механизма обнаружения или реагирования на реплей украденного refresh-токена нет. Каждый вызов refresh аннулирует старый токен и выдаёт новый; предъявление погашенного refresh-токена отзывает всё семейство токенов и форсирует повторную аутентификацию. Одновременный refresh из двух вкладок (безобидная гонка) не вызывает ложного срабатывания детекта реюза — решается коротким grace-окном или single-flight-локом. Абсолютное время жизни refresh-токена ограничено (14–30 дней) с idle-окном, чтобы брошенные сессии умирали. Ты можешь назвать условие гонки, вызывающее ложный отзыв, и обосновать выбранную длительность grace-окна.
Эталонный разбор (спойлер)

Почему PKCE: authorization code проходит через query-строку редиректа браузера — публичный, логируемый, утекаемый через Referer канал. PKCE привязывает будущий обмен токена к клиенту, инициировавшему поток, требуя доказательства verifier на /token. Перехваченный код бесполезен без verifier, делая участок редиректа безопасным для публичных клиентов, которые не могут хранить client secret.

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

Фиксация алгоритма обязательна: верификатор, читающий алгоритм из заголовка JWT и выбирающий ключ соответственно, можно обмануть через alg:none (подпись не нужна) или HS256-с-публичным-ключом (атакующий подписывает известным публичным ключом, верификатор проверяет тем же ключом и пропускает). Зафиксируй ожидаемый алгоритм в верификаторе; никогда не доверяй заголовку.

Класс утечек «токен в логе»: сырые токены, залогированные в структурированный вывод, отправленные в заголовках Referer через URL или записанные в отчёты о крашах, утекают к третьим сторонам без сетевого вызова. Превенция — не «ротировать быстрее», а «никогда не давать токену попасть в строку лога»: хешировать или редактировать в точке эмиссии, а не после.

Сделай по-сеньорски

  • Добавь sender-constrained токены через DPoP (или mTLS-bound токены): привяжи каждый access-токен к ключу клиента, чтобы украденный bearer-токен был бесполезен без proof-of-possession.
  • Реализуй device authorization grant (device flow) для устройств с ограниченным вводом, с user_code, verification_uri и корректным slow_down/backoff поллинга.
  • Проведи ротацию ключа подписи без простоя: опубликуй новый kid в JWKS, держи перекрытие старого и нового дольше максимального TTL токена, затем выведи старый ключ, пока живые токены продолжают проверяться.
  • Храни сессию в httpOnly, SameSite cookie и обоснуй выбор против localStorage под threat model XSS.

Навыки

authorization code flowPKCEstate / CSRF defensetoken storage