RFC 9449 — Demonstrating Proof of Possession (DPoP) — Versola Docs
VersolaVersola/docs
0.6.2versola.kzGitHub

RFC 9449 — Demonstrating Proof of Possession (DPoP)

OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer

Спецификация: RFC 9449

DPoP привязывает access token — а если клиент запросил offline access, то и выданный вместе с ним refresh token — к паре ключей, которую держит клиент, так что перехваченный без приватного ключа токен нельзя воспроизвести от чужого имени. auth выпускает и проверяет proof’ы на token endpoint и /userinfo; edge, компонент resource server перед проксируемыми API, применяет полный набор проверок — включая обязательный nonce, который он всегда требует — на каждый проксируемый запрос.

Валидация Proof JWT

  • Заголовок typ: dpop+jwt
  • Allowlist алгоритмов подписи — берётся из dpop_signing_alg_values_supported в документе метаданных authorization server, так что набор, который узнают клиенты, и набор, по которому проверяется proof, — одно и то же значение. ES256 и PS256, если документ о них молчит; для RS256 верификатор есть, но в этот набор он не входит (FAPI 2.0 его запрещает, так что деплоймент, которому он нужен, должен назвать его явно)
  • Публичный ключ передаётся в заголовке jwk самого proof’а и проверяется против ключа, который он несёт — а не через kid, указывающий на ключ, выпущенный этим сервером
  • Проверка подписи против этого встроенного ключа
  • htm — привязка к HTTP-методу текущего запроса
  • htu — привязка к URI текущего запроса, query и fragment отбрасываются перед сравнением
  • iat — проверяется с настраиваемым допуском (dpop.iat-leeway, по умолчанию 60с), симметрично в обе стороны
  • jti — обязателен, используется для проверки одноразовости ниже

Token Endpoint

  • DPoP опционален для каждого запроса — запрос без заголовка DPoP всё равно выдаёт обычный bearer token
  • Требуется ровно один заголовок DPoP — больше одного отклоняется до разбора любого proof’а
  • cnf.jkt — thumbprint ключа, которым доказывается владение, привязывается к выпущенному access token
  • token_type отражает привязку — DPoP, если присутствует cnf.jkt, иначе Bearer
  • error="use_dpop_nonce" со свежим заголовком ответа DPoP-Nonce на отказе — чтобы клиент повторил запрос уже с ним
  • Обязательный со стороны сервера nonce — dpop.require-nonce делает nonce обязательным для каждого proof’а, который проверяет этот endpoint. По умолчанию выключен: каждому клиенту это стоит лишнего круга, и, в отличие от проксируемого вызова API, перехваченный proof на token endpoint даёт атакующему немного

Resource Endpoints (/userinfo)

  • ath — proof должен быть привязан к реально предъявленному access token (base64url(sha256(token)))
  • Совпадение cnf.jkt — proof, подписанный любым ключом, кроме того, к которому токен был привязан при выпуске, отклоняется
  • Отказ в downgrade до Bearer — DPoP-bound токен, предъявленный по схеме Bearer, отклоняется полностью согласно §7.2, а не принимается молча
  • WWW-Authenticate: DPoP error="invalid_dpop_proof", и error="use_dpop_nonce" с заголовком DPoP-Nonce — на соответствующих ошибках
  • Требуется ровно один заголовок DPoP, как и на token endpoint
  • Обязательный со стороны сервера nonce — тот же переключатель dpop.require-nonce, действует и здесь

Edge assertion (расширение Versola, не часть RFC 9449)

edge сам терминирует DPoP для тех же bound-токенов (см. ниже) и отдельно вызывает /userinfo от имени пользователя, чтобы построить свой authorization context — вызов, к которому он не может приложить proof, потому что приватный ключ клиента никогда не покидает клиента. Вместо того чтобы принять непроверяемое предъявление bound-токена по схеме Bearer, auth принимает вместо этого короткоживущий assertion, подписанный edge (заголовок Versola-Edge-Assertion), привязанный к тенанту, который edge зарегистрирован обслуживать, и проверяемый на replay через то же кольцо, что и клиентские proof’ы. Это закрытое исключение для одного доверенного внутреннего вызывающего, а не общий fallback на bearer: assertion, подписанный не для того тенанта, от незарегистрированного edge, или повторно использованный, отклоняется точно так же, как если бы не было отправлено вообще ничего.

Edge Resource Server (проксируемые вызовы API)

  • Привязка ath и cnf.jkt, те же проверки, что и на /userinfo
  • Nonce обязателен на каждом proof, безусловно — никакая настройка не пропускает эту проверку (§9, §11.3)
  • Требуется ровно один заголовок DPoP
  • htu пересобирается из конфигурации, а не берётся из заголовка Host самого запроса

Защита от Replay

  • Одноразовость — пара (jkt, jti) proof’а записывается при первом использовании, повтор отклоняется
  • Между репликами — общее, UNLOGGED, партиционированное кольцо в Postgres, а не in-process кэш, поэтому proof, повторно использованный на другом инстансе внутри окна iat, будет пойман, а не только повтор на том же инстансе
  • Вытеснение по слотам — proof’ы раскладываются по временным слотам фиксированной ширины на основе собственного iat, и весь слот усекается целиком, как только в нём не может остаться ничего допустимого, вместо построчного удаления
  • edge дополнительно держит локальное in-memory кольцо перед общим — проверяется первым, к общему хранилищу обращаются только при локальном промахе — и деградирует, а не блокирует, если общее хранилище недоступно, откатываясь на защиту только локальным кольцом вместо отказа в каждом проксируемом вызове

Привязка Refresh Token

  • Refresh token, выпущенный вместе с DPoP-bound access token, сам является sender-constrained — последующий refresh grant должен предъявить proof для того же ключа
  • Несовпадение ключа при refresh — invalid_grant, а не молчаливая перепривязка к любому предъявленному ключу
  • Bound refresh token не ротируется — токену, который доказуемо всё ещё находится у той же стороны, которой он был выдан, ротация ничего не даёт, поэтому для него не ведутся цепочка ротации, её idempotency key и состояние повтора
  • Introspection отражает привязку — token_type: "DPoP" и cnf.jkt в ответе introspection для bound-токена

Метаданные Authorization Server

  • dpop_signing_alg_values_supported — публикуется, и это единственное место, где принимаемый набор зафиксирован: auth берёт алгоритмы, по которым проверяет proof, прямо из выдаваемого документа, а не из своего конфига, так что расхождение между ними невозможно. Алгоритм, названный там, но для которого у auth нет верификатора, отбрасывается, а не публикуется: документ не обещает того, за что proof потом будет отклонён

Метаданные клиента

  • dpop_bound_access_tokens — клиент, зарегистрированный с этим флагом, всегда использует DPoP: запрос токена от него без proof’а отклоняется с invalid_dpop_proof при каждом grant, а не отвечается bearer-токеном. По умолчанию выключен, так что клиент, не запросивший этого, по-прежнему решает на уровне каждого запроса

Привязка Authorization Code

  • Параметр authorization request dpop_jkt (§10) — клиент фиксирует на этапе authorization request ключ, против которого будет обменян его code, и code несёт этот thumbprint через весь flow логина. Обмен с proof’ом для любого другого ключа, или вовсе без proof’а, отклоняется с invalid_grant, при этом code остаётся неизрасходованным, чтобы зафиксировавший его клиент всё ещё мог им воспользоваться. Значение, не являющееся base64url SHA-256 thumbprint’ом, отклоняется прямо на /authorize, где клиенту ещё можно объяснить причину. Тот же парсер обслуживает /par, так что pushed request фиксирует ключ точно так же