Спецификация: 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 фиксирует ключ точно так же