跳到主要内容
ecdsa.com
← 全部文章

DPoP 详解:把 OAuth 令牌绑定到客户端密钥

Bearer 令牌谁持有谁就能用。绑定了 DPoP 的令牌必须同时配上客户端私钥新生成的签名才能使用,因此单独复制走的令牌毫无用处。本文介绍协议本身、客户端与服务端代码,以及在 Keycloak 签发的令牌和证明被窃取、重放和复用时,资源服务器拒绝了哪些尝试。

简短回答:DPoP(Demonstrating Proof of Possession,演示持有证明;RFC 9449,2023年9月)把访问令牌绑定到客户端生成的密钥对上。授权服务器把公钥的指纹记录在令牌中(cnf.jkt),并在响应中返回 token_type: DPoP。每次请求时,客户端都以 DPoP 方案发送令牌,并附带一个 DPoP 请求头:这是一个用私钥签名的简短 JWT,写明 HTTP 方法(htm)、URL(htu)、唯一的 jti、时间(iat)以及访问令牌的哈希(ath)。资源服务器验证证明,拒绝重复使用的 jti 值和过旧的证明,并检查证明所用的密钥正是令牌所绑定的密钥。如果访问令牌或刷新令牌存放在可能被复制走的地方(例如浏览器和移动应用),或者 FAPI 2.0 这类安全规范要求使用发送方约束令牌,就应该使用 DPoP。

DPoP 解决什么问题

OAuth 的 Bearer 令牌(RFC 6750)就像现金:谁出示它,谁就能获得访问权限。令牌会通过日志、浏览器存储、代理、崩溃报告和被攻破的依赖项泄露出去,而泄露的 Bearer 令牌在过期之前从任何地方都能使用。发送方约束令牌增加了第二个要素,而这个要素不会随令牌一起传递。OAuth 有两种标准做法:双向 TLS(RFC 8705),把令牌绑定到客户端证书;以及 DPoP,把令牌绑定到在应用层使用的密钥。DPoP 不需要 TLS 客户端证书,可以在浏览器中使用,也能穿过终结 TLS 的代理,代价是每个请求一次签名。

DPoP 做不到的事:它不对请求体或请求头签名,只对方法和 URL 签名(§4.2);如果攻击者能在客户端内部运行代码,它也无能为力,因为这些代码可以让客户端的密钥为证明签名(§11.4)。如果要对整个请求签名,参见 HTTP 消息签名。

流程

  1. 客户端生成一个密钥对(最好不可导出),并在其令牌的整个生命周期内一直保留它。
  2. 令牌请求:客户端照常发送令牌请求,并附带一个 DPoP 请求头,其中包含针对 POST 和令牌端点 URL 的证明。如果授权服务器需要 nonce,它会返回 400 和 use_dpop_nonce,并附带 DPoP-Nonce 响应头,客户端随后在证明中带上该 nonce 重试。
  3. 令牌响应:token_type 为 DPoP,而不是 Bearer。JWT 访问令牌带有 "cnf": {"jkt": "<SHA-256 JWK thumbprint>"}(§6.1,RFC 7638);不透明令牌则通过内省提供同一个值(§6.2)。签发给公共客户端的刷新令牌也会绑定到该密钥(§5)。
  4. 资源请求:Authorization: DPoP <access token>,外加一个针对本次方法和 URL 的新证明,此时证明中还包含 ath,即访问令牌的 base64url 编码 SHA-256 哈希。
  5. 资源服务器检查证明和绑定关系,它自己也可以用 401 和 WWW-Authenticate: DPoP error="use_dpop_nonce" 要求客户端提供 nonce(§9)。

证明包含以下成员(§4.2):

位置成员含义
头部typdpop+jwt,确保证明永远不会与其他 JWT 混淆
头部alg只能是非对称算法;绝不能是 none 或 HS*
头部jwk客户端的公钥;绝不能是私钥
载荷jti每个证明唯一:至少 96 位随机值,或 UUID v4
载荷htm、htuHTTP 方法,以及不含查询部分和片段的目标 URI
载荷iat创建时间
载荷ath访问令牌的哈希,出示访问令牌时必须提供
载荷nonce服务器最近一次下发的 DPoP-Nonce(如果服务器下发过)

客户端:生成证明

dpop-client.mjs(jose 6.2.13,Node.js 24.22)
import { randomUUID, createHash } from "node:crypto";
import { SignJWT, exportJWK, generateKeyPair } from "jose";

// One key pair per client instance. The private key is a non-extractable CryptoKey:
// code that can call sign() can make proofs, but cannot copy the key elsewhere.
const { privateKey, publicKey } = await generateKeyPair("ES256");
const jwk = await exportJWK(publicKey);

export const ath = (accessToken) => createHash("sha256").update(accessToken, "ascii").digest("base64url");

/** A DPoP proof for one request (RFC 9449 §4.2). */
export function dpopProof({ method, url, accessToken, nonce }) {
  const target = new URL(url);
  return new SignJWT({
    jti: randomUUID(),
    htm: method,
    htu: target.origin + target.pathname, // without query and fragment
    ...(accessToken && { ath: ath(accessToken) }),
    ...(nonce && { nonce }),
  })
    .setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk })
    .setIssuedAt()
    .sign(privateKey);
}

/** Calls fetch with a fresh proof; retries once when the server asks for a nonce. */
export async function dpopFetch(url, { method = "GET", accessToken, headers = {}, body } = {}, state = {}) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const proof = await dpopProof({ method, url, accessToken, nonce: state.nonce });
    const res = await fetch(url, {
      method,
      body,
      headers: { ...headers, DPoP: proof, ...(accessToken && { Authorization: `DPoP ${accessToken}` }) },
    });
    if (res.headers.has("dpop-nonce")) state.nonce = res.headers.get("dpop-nonce");
    const challenge = res.headers.get("www-authenticate") ?? "";
    const bodyText = res.status === 400 ? await res.clone().text() : "";
    const askedForNonce = challenge.includes('error="use_dpop_nonce"') || bodyText.includes('"use_dpop_nonce"');
    if (!askedForNonce || attempt === 1) return res;
  }
}

在浏览器中,同样的做法可以借助 Web Crypto API 实现:用 extractable: false 生成的密钥可以保存在 IndexedDB 中并用于签名,但脚本无法读取其私钥部分。这能防止密钥被盗,却防不了页面上的恶意脚本让它签名。

资源服务器:检查证明

RFC 9449 §4.3 列出了 12 项检查,这些检查“可以按任意顺序执行”,§7 又补充了令牌绑定。下面的验证器针对单个请求实现了这些检查,注释中标有步骤编号:

dpop-verify.mjs(jose 6.2.13)
import { createHash, randomBytes } from "node:crypto";
import { EmbeddedJWK, calculateJwkThumbprint, createRemoteJWKSet, jwtVerify } from "jose";

const PROOF_ALGS = ["ES256", "EdDSA", "PS256"]; // asymmetric only; never none or HS*
const MAX_PROOF_AGE = 60; // seconds
const SKEW = 5; // seconds a client clock may run ahead

export class DpopError extends Error {
  constructor(error, description, headers = {}) {
    super(description);
    this.error = error; // invalid_token | invalid_dpop_proof | use_dpop_nonce
    this.headers = headers;
  }
}

const sha256 = (text) => createHash("sha256").update(text, "ascii").digest("base64url");
const normalizeHtu = (htu) => {
  const u = new URL(htu); // lowercases scheme and host, drops default ports (RFC 3986 §6.2.2-6.2.3)
  return u.origin + u.pathname;
};

/** Resource-server side of RFC 9449: checks §4.3 and §7 for one request. */
export function createDpopVerifier({ issuer, jwksUri, audience, publicOrigin, requireNonce }) {
  const jwks = createRemoteJWKSet(new URL(jwksUri));
  const seen = new Map(); // "htu jti" -> expiry, for replay detection (§11.1)
  const nonce = randomBytes(16).toString("base64url"); // rotate it on a timer in production

  return async function verify(req) {
    // §7.1: a DPoP-bound access token arrives with the DPoP scheme, never as Bearer (§7.2).
    const [scheme, accessToken, ...extra] = (req.headers.authorization ?? "").split(" ");
    if (scheme !== "DPoP" || !accessToken || extra.length > 0) {
      throw new DpopError("invalid_token", "DPoP authorization scheme required");
    }
    // Steps 1-2: exactly one DPoP header holding one JWT.
    const proofs = req.headersDistinct.dpop ?? [];
    if (proofs.length !== 1) throw new DpopError("invalid_dpop_proof", `${proofs.length} DPoP headers`);
    // Steps 3-7 and 11: claims present, typ, asymmetric alg, signature with the embedded
    // public key (EmbeddedJWK refuses private keys), and iat within the window.
    let proof;
    try {
      proof = await jwtVerify(proofs[0], EmbeddedJWK, {
        typ: "dpop+jwt",
        algorithms: PROOF_ALGS,
        requiredClaims: ["jti", "htm", "htu", "iat", "ath"],
        maxTokenAge: MAX_PROOF_AGE,
        clockTolerance: SKEW,
      });
    } catch (e) {
      throw new DpopError("invalid_dpop_proof", e.message);
    }
    const { payload: p, protectedHeader } = proof;
    // Steps 8-9: the proof was made for this method and this URI.
    const target = new URL(req.url, publicOrigin);
    if (p.htm !== req.method) throw new DpopError("invalid_dpop_proof", `htm ${p.htm} != ${req.method}`);
    if (normalizeHtu(p.htu) !== target.origin + target.pathname) {
      throw new DpopError("invalid_dpop_proof", `htu ${p.htu} != ${target.origin}${target.pathname}`);
    }
    // Step 10: the server's current nonce, if this server hands them out.
    if (requireNonce && p.nonce !== nonce) {
      throw new DpopError("use_dpop_nonce", "Resource server requires nonce in DPoP proof", { "DPoP-Nonce": nonce });
    }
    // Replay: each jti once per target URI while the proof could still be accepted.
    const key = `${p.htu} ${p.jti}`;
    const now = Date.now();
    for (const [k, expiry] of seen) if (expiry < now) seen.delete(k);
    if (seen.has(key)) throw new DpopError("invalid_dpop_proof", "DPoP proof already used");
    seen.set(key, (p.iat + MAX_PROOF_AGE + SKEW) * 1000);
    // The access token itself: issuer, audience, signature, expiry.
    let token;
    try {
      ({ payload: token } = await jwtVerify(accessToken, jwks, { issuer, audience, algorithms: ["RS256"] }));
    } catch (e) {
      throw new DpopError("invalid_token", e.message);
    }
    // Step 12: the proof belongs to this token, and the token is bound to the proof's key.
    if (p.ath !== sha256(accessToken)) throw new DpopError("invalid_dpop_proof", "ath does not match the access token");
    if (token.cnf?.jkt !== (await calculateJwkThumbprint(protectedHeader.jwk, "sha256"))) {
      throw new DpopError("invalid_token", "Invalid DPoP key binding");
    }
    return token;
  };
}

/** Turns a DpopError into the 401 response RFC 9449 §7.1 describes. */
export function challenge(err) {
  const description = err.message.replaceAll('"', "'");
  return {
    status: 401,
    headers: {
      "WWW-Authenticate": `DPoP error="${err.error}", error_description="${description}", algs="${PROOF_ALGS.join(" ")}"`,
      ...err.headers,
    },
  };
}

有三个细节比看上去更重要:

  • publicOrigin 是配置项,而不是 Host 请求头。在负载均衡器后面,服务器看到的是内部 URL。根据请求头重建 URL,要么让每个请求都失败,要么就得信任一个由客户端控制的请求头。
  • 防重放存储按目标 URI 区分,保留时长只需覆盖证明的有效窗口。一个端点后面有多个实例时,这个存储必须共享,RFC 9449 也承认这“未必总是可行”;替代方案是短期有效的服务器 nonce。
  • 拒绝以 Bearer 方案发送的 DPoP 绑定令牌(§7.2)。否则,对窃取令牌的人来说,绑定就成了可有可无的东西。

用 Keycloak 签发的令牌实测

实验环境运行 Keycloak 26.8.1(quay.io/keycloak/keycloak:26.8.1,开发模式),其中配置了一个机密客户端 orders-cli,要求使用绑定 DPoP 的访问令牌(客户端属性 dpop.bound.access.tokens),还配置了一个添加 orders-api 的受众映射器。一个驱动脚本使用 dpop-client.mjs 生成的证明申请令牌,在 4000 端口运行一个基于 dpop-verify.mjs、开启了 nonce 的资源服务器,然后模拟攻击者拿到复制的令牌或复制的请求后会做的尝试:

输出:Keycloak 26.8.1 与上文的资源服务器(node:24.22.0-bookworm-slim)
dpop_signing_alg_values_supported: PS384 RS384 EdDSA ES384 ES256 RS256 ES512 PS256 PS512 RS512
token request, proof with htm GET: 400 {"error":"invalid_request","error_description":"DPoP HTTP method mismatch"}
token request without DPoP header: 400 {"error":"invalid_request","error_description":"DPoP proof is missing"}
token response: 200, token_type DPoP
access token aud ["orders-api","account"], cnf {"jkt":"jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k","kc-jkt-type":"DPoP"}
thumbprint of the client's DPoP key: jxtFYlJLzPaVIiNgZHu-vReD5s26NbuoroqLJ-H7A1k
first call, no nonce yet                     401 DPoP error="use_dpop_nonce", error_description="Resource server requires nonce in DPoP proof", algs="ES256 EdDSA PS256"
retry with the server's nonce                200 {"orders":[],"client":"orders-cli"}
proof sent: {"typ":"dpop+jwt","alg":"ES256","jwk":{"kty":"EC","x":"M5K5xM809NM5egv0kblUzobF_h2cNudX-6ENCI0MjiQ","y":"qtatemMO_oibrlk6VpoXDbHPtCiZNksoyVMcIGRx33w","crv":"P-256"}} {"jti":"993999b4-d7f8-4018-9f8e-e20f2a109a71","htm":"GET","htu":"http://127.0.0.1:4000/orders","ath":"CFA2MS0HVrzwNCH1DJZgOjreMZ0qSfyrNWXR6W-rKb8","nonce":"ItQnwia8Goi2bVqV4toJFQ","iat":1792497394}
dpopFetch, no nonce known (2 requests)       200 {"orders":[],"client":"orders-cli"}
same proof replayed                          401 DPoP error="invalid_dpop_proof", error_description="DPoP proof already used", algs="ES256 EdDSA PS256"
stolen token sent as Bearer                  401 DPoP error="invalid_token", error_description="DPoP authorization scheme required", algs="ES256 EdDSA PS256"
stolen token, attacker's own key             401 DPoP error="invalid_token", error_description="Invalid DPoP key binding", algs="ES256 EdDSA PS256"
GET proof used for DELETE                    401 DPoP error="invalid_dpop_proof", error_description="htm GET != DELETE", algs="ES256 EdDSA PS256"
proof made for another access token          401 DPoP error="invalid_dpop_proof", error_description="ath does not match the access token", algs="ES256 EdDSA PS256"
proof created two minutes ago                401 DPoP error="invalid_dpop_proof", error_description="'iat' claim timestamp check failed (too far in the past)", algs="ES256 EdDSA PS256"

运行结果说明:

  • 绑定确实生效。Keycloak 返回了 token_type DPoP,以及与客户端密钥指纹相同的 cnf.jkt。它还在 jkt 旁边添加了一个非标准成员 kc-jkt-type;资源服务器必须忽略自己不认识的成员,上面的验证器就是这样做的。
  • 仅凭窃取的令牌是不够的。以 Bearer 方案发送时,令牌被拒绝;附上攻击者用自己的密钥生成的有效证明发送时,未能通过绑定检查。复制的请求同样失败:同一个证明无法重放,针对 GET 的证明不能用于 DELETE,两分钟前创建的证明则已经过旧。
  • ath 把证明限定在一个令牌上。为同一客户端、同一密钥的另一个访问令牌生成的有效证明被拒绝了。
  • nonce 的代价是一次往返。第一个不带 nonce 的请求收到了 401 use_dpop_nonce;dpopFetch 第一次调用需要 2 个请求,之后每次只需 1 个。

Keycloak 的令牌端点同样会强制检查证明。下面是第二个简短脚本,复用了上面的客户端模块:

as-replay.mjs
// Does Keycloak's token endpoint accept the same DPoP proof twice? And a proof with a wrong htu?
import { dpopProof } from "./dpop-client.mjs";

const ISSUER = "http://127.0.0.1:8080/realms/demo";
const { token_endpoint } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const send = (proof) =>
  fetch(token_endpoint, {
    method: "POST",
    headers: {
      "content-type": "application/x-www-form-urlencoded",
      authorization: `Basic ${Buffer.from(`orders-cli:${process.env.CLI}`).toString("base64")}`,
      DPoP: proof,
    },
    body: "grant_type=client_credentials",
  });
const proof = await dpopProof({ method: "POST", url: token_endpoint });
for (const label of ["first use of a proof", "same proof again"]) {
  const res = await send(proof);
  const body = await res.json();
  console.log(`${label.padEnd(24)} ${res.status} ${body.token_type ?? JSON.stringify(body)}`);
}
const wrongUri = await send(await dpopProof({ method: "POST", url: `${ISSUER}/protocol/openid-connect/userinfo` }));
console.log(`${"proof for another URI".padEnd(24)} ${wrongUri.status} ${await wrongUri.text()}`);
输出
first use of a proof     200 DPoP
same proof again         400 {"error":"invalid_request","error_description":"DPoP proof has already been used"}
proof for another URI    400 {"error":"invalid_request","error_description":"DPoP HTTP URL mismatch"}

有一处偏差需要了解:RFC 9449 §5 规定,令牌端点收到无效证明时返回错误码 invalid_dpop_proof,而 Keycloak 26.8.1 对上面所有证明问题都返回 invalid_request。编写客户端时,只针对 use_dpop_nonce 做分支处理,把 DPoP 令牌请求返回的其他错误当作需要报告的失败,而不是需要匹配的错误码。

nonce:服务器为什么需要它

没有服务器 nonce 时,证明是否新鲜完全取决于 iat,而这个值由客户端自己决定。任何控制客户端的人(包括它的合法用户)都可以预先签好标注未来时间的证明,之后在一台没有该密钥的机器上使用(§11.2,“预生成”)。由服务器提供、不可预测并由服务器轮换的 nonce,让证明按服务器的时钟过期。它还消除了时钟偏差的影响:服务器可以把自己的时间编码进 nonce,而不必信任 iat(§11.1)。

§8 和 §9 中的规则:

  • 授权服务器用 400 和 "error": "use_dpop_nonce" 发出信号,资源服务器用 401 和 WWW-Authenticate: DPoP error="use_dpop_nonce"。两者都在 DPoP-Nonce 中发送 nonce 值。
  • 服务器一旦发送过 nonce,就不得再接受不带 nonce 的证明(§11.3)。
  • 服务器可以在成功响应中下发下一个 nonce,省去额外的往返;这类响应不应允许缓存。
  • 只有当服务器在 Access-Control-Expose-Headers 中列出 DPoP-Nonce 和 WWW-Authenticate 时,浏览器客户端才能看到这两个响应头。否则,单页应用无法读取服务器要求它使用的 nonce。

例如,Okta 的授权服务器对第一个 DPoP 令牌请求的响应是“Authorization server requires nonce in DPoP proof”(Okta 指南),所以客户端从一开始就需要实现重试。

绑定授权码:dpop_jkt

DPoP 在令牌端点绑定令牌,但授权码更早就已签发,而且经过浏览器传递。在授权请求中加入 dpop_jkt(§10),客户端就能预先声明它将使用的密钥的指纹,授权服务器则会拒绝证明使用了其他密钥的令牌请求。与 PKCE 结合,只要客户端为每个授权请求使用新密钥,就能端到端地保护整个流程。FAPI 2.0 要求使用 DPoP 的授权服务器支持这种绑定,但客户端可以自行选择是否使用(FAPI 2.0 安全规范 §5.3.2.1 和 §5.3.3.1)。

何时使用 DPoP

场景是否使用 DPoP?
持有刷新令牌的单页应用或移动应用是:签发给公共客户端的刷新令牌会绑定到密钥,这正是 DPoP 最能发挥作用的地方
FAPI 2.0(开放银行及类似场景)实际上必须使用:FAPI 2.0 要求通过 mTLS 或 DPoP 使用发送方约束令牌
已经部署双向 TLS 的服务器到服务器通信mTLS 绑定(RFC 8705)是更简单的选择
从不向浏览器暴露令牌的机密 Web 应用可选:令牌保留在服务端,其刷新令牌也已经与客户端身份验证绑定
你无法控制、只接受 Bearer 的资源服务器作用有限:在那里令牌只能作为 Bearer 令牌使用,得不到这种保护(§7.2)

Keycloak(上文已测试)、Okta 和 Auth0(文档)都为各自的令牌端点提供了 DPoP 文档,服务器还会在元数据中以 dpop_signing_alg_values_supported 公布可接受的证明算法(§5.1)。缺口通常在资源服务器一侧,这也正是上面这个验证器的意义所在。

常见错误

  1. 只检查证明,不检查绑定。由任意密钥签名的证明本身都是有效的;只有 cnf.jkt 才把它和令牌联系起来。
  2. 以 Bearer 方案发送的令牌也照样接受。这样攻击者只需改用 Bearer 方案即可。
  3. 拿 htu 与带查询字符串的 URL 或内部 URL 比较。htu 不含查询部分和片段,必须与规范化之后的公开 URL 一致。
  4. 既没有防重放存储,也没有 nonce。这样复制的证明在整个 iat 窗口内都能再次使用。
  5. 每个请求都换新密钥。只要绑定到该密钥的令牌仍在使用,密钥就必须保持不变。
  6. 使用对称 alg,或在 jwk 中放入私钥。两者都被 §4.2 禁止;jose 的 EmbeddedJWK 会拒绝后者。

参考资料