Суть Читай реальный код OAuth/OIDC — обмен PKCE, валидацию id_token, ротацию refresh token и привязку DPoP — находи пропущенную или вывернутую обязательную проверку и выбирай фикс.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Каждая CVE в OAuth — это одна пропущенная или вывернутая проверка, спрятанная в коде, который выглядит верным. Читай каждый сниппет как security-ревьюер, находи пропущенный обязательный шаг и выбирай фикс, который закрывает реальную дыру.
Цель
Отработай цикл ревью, который ловит баги OAuth до выката: читай путь обмена или валидации, определи, какая обязательная проверка отсутствует или неверна, и применяй точный фикс, а не размытое «усиление».
Сниппет 1 — обмен токенов
// callback handler after the IdP redirects back with ?code=...&state=...async function handleCallback(req, session) { const { code, state } = req.query; const res = await fetch(TOKEN_ENDPOINT, { method: "POST", body: new URLSearchParams({ grant_type: "authorization_code", code, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: session.codeVerifier, }), }); return res.json(); // tokens}
Викторина
Completed
PKCE code_verifier передан верно. Какая обязательная проверка отсутствует в этом callback и что она позволяет атакующему?
Heads-up PKCE привязывает code к этому клиенту; он не доказывает, что этот callback относится к потоку, который начал этот пользователь. State — независимая анти-CSRF-проверка, и её здесь нет.
Heads-up Запрос на /token обязан нести исходный code_verifier, чтобы сервер захэшировал и сравнил его с сохранённым challenge. Отправка challenge тут полностью сломала бы PKCE.
Heads-up redirect_uri обязан присутствовать и точно совпадать с зарегистрированным значением, чтобы сервер мог его подтвердить; его удаление ослабляет поток, а не чинит пропущенную проверку state.
Сниппет 2 — валидация id_token
import jwt from "jsonwebtoken";function verifyIdToken(idToken, jwks) { const header = decodeHeader(idToken); const key = jwks.find((k) => k.kid === header.kid); // verify using whatever the token says it used const claims = jwt.verify(idToken, key.pem, { algorithms: [header.alg] }); if (claims.iss !== EXPECTED_ISS) throw new Error("bad iss"); if (claims.exp < Date.now() / 1000) throw new Error("expired"); return claims;}
Викторина
Completed
Этот валидатор проверяет iss и exp. Какие две обязательные проверки сломаны или отсутствуют и почему это важно?
Heads-up Деление Date.now() на 1000 уже переводит в секунды под exp. Реальные дефекты — доверие к header.alg и пропуск aud и nonce.
Heads-up Выбор ключа по kid верен и обязателен. Дефекты — доверие к алгоритму и пропущенные проверки aud и nonce, а не поиск ключа.
Heads-up Токен фабрикует атакующий, а не IdP. Доверяя header.alg, валидатор можно увести на alg=none или HS256 с публичным ключом в роли секрета, независимо от того, чем обычно подписывает IdP.
Сниппет 3 — ротация refresh
async function refresh(presentedRT) { const record = await db.refreshTokens.find(presentedRT); if (!record) throw new Error("unknown refresh token"); // issue a fresh pair const next = await mintTokens(record.userId); await db.refreshTokens.insert(next.refreshToken, { userId: record.userId }); return next;}
Викторина
Completed
Это выпускает новый refresh token на каждый вызов. Почему оно не обнаруживает украденный refresh token и каков фикс?
Heads-up Переиспользование одного долгоживущего refresh token — это ровно то, что ротация заменяет. Фикс — инвалидировать старый токен и обнаруживать его повторное использование, а не перестать выпускать новые.
Heads-up Обновление использует refresh token, а не access token. Дефект — отсутствие пометки об использовании и обнаружения replay у самого refresh token.
Heads-up Короткий TTL ограничивает окно, но украденный токен всё равно работает до истечения незамеченным. Именно ротация с обнаружением replay вскрывает кражу.
Сниппет 4 — проверка DPoP proof
function verifyDpop(proofJwt, accessToken, req) { const { header, payload } = parse(proofJwt); verifySignature(proofJwt, header.jwk); // signed by embedded key if (payload.htm !== req.method) throw new Error("htm"); if (payload.htu !== req.url) throw new Error("htu"); if (Math.abs(now() - payload.iat) > 60) throw new Error("iat skew"); return true;}
Викторина
Completed
Это проверяет подпись proof, htm, htu и iat. Каких двух привязок DPoP не хватает и какую атаку открывает каждый пробел?
Heads-up Они доказывают, что proof свежий и для этого запроса, но не то, что он относится к ЭТОМУ токену или ЭТОМУ ключу клиента. ath, совпадение thumbprint cnf.jkt и кэш jti от replay — недостающие привязки.
Heads-up DPoP proof подписан собственным ключом клиента, который несётся в header.jwk и привязан через cnf.jkt — не JWKS-ключом IdP. Дефект — отсутствие проверок ath, jkt и jti.
Heads-up htu привязывает proof к целевому URI и должен проверяться (при необходимости с нормализацией). Его отбрасывание ослабляет proof, а не чинит отсутствующие проверки ath/jkt/jti.
Итог
Каждый сниппет ломался так же, как ломается реальный код OAuth — пропуском или выворачиванием одной обязательной проверки при верном виде: отсутствие сравнения state открывает login CSRF; доверие к header.alg и пропуск aud/nonce открывают algorithm-confusion, audience-confusion и replay; выпуск без пометки об использовании убивает ротацию; а DPoP-валидатор без ath, совпадения thumbprint cnf.jkt и кэша jti от replay на деле не sender-constrained. Проверяй полный набор: читай путь, назови отсутствующую проверку, примени точный фикс.