DPoP 采用指南

本指南详细介绍了如何在与 Google 的 OAuth 平台的 OAuth 2.0 集成中实现 DPoP(Demonstrating Proof-of-Possession,证明持有) 。DPoP(在 RFC 9449中定义)通过以加密方式将 令牌绑定到客户端生成的非对称密钥对,保护您的 应用免受令牌盗窃和重放攻击。

授权代码流程变更

如需将 DPoP 添加到现有的 OAuth 2.0 授权代码流程,您需要生成并存储密钥对,构建 DPoP 证明 JWT,并在将授权代码换成刷新令牌时将该证明作为 HTTP 标头添加,如图 1 的第 5 步和第 6 步所示。

使用 DPoP 的授权代码流程
图 1.使用 DPoP 的授权代码流程中的事件序列。

授权代码请求

授权请求的创建方式与正常情况相同。例如:

$ curl -G "https://accounts.google.com/o/oauth2/v2/auth" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8080" \
  --data-urlencode "response_type=code" \
  --data-urlencode "scope=calendar.readonly" \
  --data-urlencode "state=AI1Bvapj7E5SDmtW4gohcA" \
  --data-urlencode "code_challenge=PO4pPROl-31Wy9fVZ7uTW9Ga6CrjrSKsf4AAtx_JNM8" \
  --data-urlencode "code_challenge_method=S256" \
  --data-urlencode "nonce=PrMfmSNAvJFPQ7GnlEKUaw" \
  --data-urlencode "access_type=offline" \
  --data-urlencode "prompt=consent"

作为重定向 URI 参数返回的授权代码用于构建 DPoP 证明。刷新令牌绑定到证明,该证明作为 HTTP 标头包含在对令牌端点的所有后续请求中。

纯粹的无密钥客户端 SPA 无法直接使用 DPoP,因为它们需要 client_secret 并且对 DPoP-Nonce 标头存在 CORS 限制。如需保护 SPA,请通过充当机密客户端的 Backend-for-Frontend (BFF) 路由流量,启用 access_type=offline,并利用 DPoP 在服务器端绑定刷新令牌。

构建 DPoP 证明

证明包含 JOSE 标头和载荷。

如需构建标头,请生成 EC P-256 (ES256) 密钥对,并在 jwk 参数中添加公钥坐标(xy)。您也可以使用 RSA 密钥对,但不建议这样做,因为计算成本较高。

以下是一个 JOSE 标头示例:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "VC91y9ZYdfSWaDv8JaI6gx5ifOw2rn3YdqkAB51Uu6E",
    "y": "ikPjOtea4k7fWPVrRYwaA4Ww6iVY3pOOICotHwwGV3o"
  }
}

如需构建证明载荷,您需要四个值。

两个声明:htm: POSThtu: https://oauth2.googleapis.com/token 是固定值,在向 Google 的令牌端点发出请求时不会发生变化。

另外两个声明:iatjti 必须为每个请求生成。iat 的值是 issued-at 时间戳,并且每个请求都会发生变化。JWT ID (jti) 声明的值取决于交换类型。当授权 代码换成访问令牌和刷新令牌时,jti 的值是 授权代码的 Base-64 和网址编码的 SHA256 哈希值,例如 jti = BASE64URL(SHA-256(authorization_code))

以下是一个载荷正文示例:

{
  "jti": "o29CN8LIY0l_N8iy5-ilon1guad9NFQHFOdXTzrBNck",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "iat": 1784822025
}

JOSE 标头和载荷正文编码为 JWT (RFC7519),以便在令牌请求的 DPoP HTTP 标头中直接使用:

$ curl -X POST https://oauth2.googleapis.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6\
       IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZ\
       k93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3\
       c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhMSVkwbF9OOGl5NS\
       1pbG9uMWd1YWQ5TkZRSEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh\
       0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwiaWF0IjoxNzg0ODIy\
       MDI1fQ.OSdQCmqTng_uZmGK5UXf8hcEMtoOu7ucmYtl5mx4901RXnj6fJRJQmIeTq\
       fhprRBTG_RSJv2fPcWDqvQbDW7YA" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=4/0AXEQxIDNpLD-qpSIvjHb2Hl10uS_2sk2GBRpO8UJQ78YZF3hZ9LB9kTA1xYLD4xisi4C5w" \
  --data-urlencode "redirect_uri=http://127.0.0.1:8080" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET" \
  --data-urlencode "code_verifier=q8ZztyVv7HH8E2M-SEL8WaB-7CPs68rejN5UZ9OdYgo"

系统会返回 DPoP 绑定的刷新令牌以及 DPoP-Nonce HTTP 标头,例如:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI

{
  "access_token": "ya29.a0ARGnu0aebRL97B91dmvm14gTug5wpItFf9MVWq12Hja6yv09A_qxa4T73_z2gFbf32qR4RXispQ7vnOzv6gn0APLQrF51LVa6AOqCVPH2Tupocv8y0JHu4ByEbvgXEEhiHEU8Xa9_w3i-PKBPsKWiLi210RCZdqJjLXkcRrGnoPPjbGPzOPtm6KCJjPrNHG16caOWecaCgYKASESARASFQHGX2MiBn7ihbbk_n-buCbOfl2TDA0206",
  "expires_in": 3599,
  "refresh_token": "1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM",
  "scope": "https://www.googleapis.com/auth/calendar.readonly",
  "token_type": "Bearer"
}

Google 的授权服务器生成的 Nonce 必须包含在每个后续令牌请求中。请注意,Nonce 值只能使用一次,如果 Nonce 值缺失、无效、已过期或重复使用,系统会拒绝该值并返回 HTTP 400 响应。在这种情况下,系统会返回新的 Nonce 以供重试时使用。

令牌刷新流程变更

如需更新现有的 OAuth 2.0 令牌刷新流程,您需要在将刷新令牌换成新令牌时生成并发送 DPoP 证明作为 HTTP 标头,如图 2 的第 2 步到第 5 步所示。

使用 DPoP 的令牌刷新流程
图 2.令牌刷新流程中的事件序列,包括错误处理和重试。

构建 DPoP 证明

为令牌刷新构建证明的方法与授权代码场景不同。JOSE 标头的构建方式与之前构建授权代码请求时所述的方式相同。证明正文的构建方式类似,但包含 nonce 声明,并且 jti 包含唯一的随机字符串。

如需构建载荷正文,您必须在 nonce 声明中添加之前返回的 DPoP-Nonce HTTP 标头值,并为每个请求更新 issued-at 时间戳 (iat)。JWT ID (jti) 是为每个请求生成的唯一随机字符串,使用内置 WebCrypto API crypto.getRandomValues(new Uint8Array(24)) 并对字符串进行 Base64网址 编码。

以下是一个包含 jtinonceiat 的载荷正文示例:

{
  "jti": "o29CN8ZIY0l_K8iy5-ilon1gwad9NF6HFOdXTzrBNck",
  "htm": "POST",
  "htu": "https://oauth2.googleapis.com/token",
  "nonce": "AN3XwJjZsjnb0ZuWkRlek8QU7wY-Zhf-5IP6tO0tORz0KgtDT1Bo8FX-w4nz3r5lnepI",
  "iat": 1784822025
}

JOSE 标头和载荷正文编码为 JWT (RFC7519),以便在令牌请求的 DPoP HTTP 标头中直接使用。

证明作为 DPoP 标头添加到令牌刷新请求中:

$ curl -X POST https://oauth2.googleapis.com/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6\
       IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiVkM5MXk5WllkZlNXYUR2OEphSTZneDVpZ\
       k93MnJuM1lkcWtBQjUxVXU2RSIsInkiOiJpa1BqT3RlYTRrN2ZXUFZyUll3YUE0V3\
       c2aVZZM3BPT0lDb3RId3dHVjNvIn19.eyJqdGkiOiJvMjlDTjhaSVkwbF9LOGl5NS\
       1pbG9uMWd1YWQ5TkY2SEZPZFhUenJCTmNrIiwiaHRtIjoiUE9TVCIsImh0dSI6Imh\
       0dHBzOi8vb2F1dGgyLmdvb2dsZWFwaXMuY29tL3Rva2VuIiwibm9uY2UiOiJBTjNY\
       d0pqWnNqbmIwWnVXa1JsZWs4UVU3d1ktWmhmLTVJUDZ0TzB0T1J6MEtndERUMUJvO\
       EZYLXc0bnozcjVsbmVwSSIsImlhdCI6MTc4NDgyMjAyNX0.MEQCIDm09AXo2c9sov\
       GrTUkrbEB_k9mra_Dkji-CQ9mSZVP1AiBxbiqkCE7Dt9RKyUT_3kj7q1vCvVggwnW\
       JNX3P3vO1mw" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=1//06dUPZ9FIBQm3CgYIARAAGAYSNwF-L9IrJwuIEKUA_zbBPU-xoCDGM0QrDu7-jv7cMQZ0kARPUK9WhwfFFfbOVEgXDQKmFh4w9GM" \
  --data-urlencode "client_id=YOUR_CLIENT_ID.apps.googleusercontent.com" \
  --data-urlencode "client_secret=YOUR_CLIENT_SECRET"

当使用已过期、不正确或重复使用的 Nonce 时,或者在不同的 OAuth 工作流之间转换时(例如从初始授权代码交换转移到令牌刷新请求),Google 的服务器会强制执行工作流隔离。这意味着,服务器会无条件拒绝 Nonce 并返回 HTTP 400 use_dpop_nonce 质询,以便为新工作流建立新的 Nonce 命名空间。

以下是一个 400 响应示例,该响应要求重试并使用 DPoP-Nonce 值构建新证明:

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07Kf85RJXmltUhiAiELLPPrJ4zOi66zWxU1uDZbhRcahFBYvT0WlcjSSXULXknSA

{
  "error": "use_dpop_nonce",
  "error_description": "New DPoP nonce issued due to invalid or expired challenge."
}

成功后,系统会返回新的 Nonce 和短期访问令牌:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
DPoP-Nonce: AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg=

{
  "access_token": "ya29.a0ARGnu0bDj9BAQYVbF5hi3vw-brBUZBZu1bnInk1hS7gueqEb6QPqUjDGb0MMj9A0QX5FRrJo3FDw-DEDtvVbRUdeCgjwsL_LVVFXz-p-MUyiFyRoufI4KC0Go9aq5cEjD_BWvOJLMSIY6_EnwnhqDgk0XxvzaaAxDnv8PXJAGev_UotcfApstqi0NCxbfi-6Kgull9QaCgYKAUQSARASFQHGX2MiZpMjRS6z4S0RjOkNxn2o1Q0206",
  "expires_in": 3599,
  "scope": "https://www.googleapis.com/auth/calendar.readonly",
  "token_type": "Bearer",
  "challenge": "AO4t07IXuovyCbtLEr6VVFZQ_Kb78MMOXTt6-CyZpsJeF62HZ3P_EW55XbWqYcU76Jg"
}

保存 DPoP-Nonce 值以供在下一个请求中使用。

如需了解更多详情和建议,请参阅 为网络服务器应用使用 OAuth 2.0最佳实践