RH
RouterHub AI API 中转站导航与平台推荐
博客

Google OAuth 刷新令牌开始绑定密钥

Google OAuth 的 DPoP 只绑定刷新令牌,Agent 需要把密钥、Nonce 和委派链路一起设计。

muchacha 2026-10-17 02:24:45

正文

关键变化是刷新令牌不再只是字符串

Google OAuth Platform 的 DPoP 支持把刷新令牌绑定到客户端生成的非对称密钥:客户端在授权码交换和刷新请求中,用对应私钥签名 DPoP Proof JWT,授权服务器据此确认请求方持有绑定密钥。令牌被单独复制出去后,攻击者没有私钥就不能按同一流程使用它。Google 的实现没有把 DPoP 扩展到每一次 Gmail、Calendar 或 Drive API 调用,返回的访问令牌仍是 Bearer,因此这不是“所有 API 请求都加签”,而是把最需要长期保存的刷新凭证放在密钥约束之下。

这次变化对 AI Agent 的直接影响,是 OAuth 客户端不能再把刷新令牌当作可任意搬运的配置项。Agent 如果代表用户调用工具,至少要明确谁持有私钥、令牌在哪一跳刷新、Nonce 如何按工作流更新,以及多 Agent 委派时是否需要交换成下一跳令牌。Google 官方博客把 DPoP 与多 Agent、MCP 和 Cloud Secure Token Service(STS)放在同一讨论中,但它没有把 DPoP 变成跨 Agent 共享私钥的方案。要先画清信任边界,再决定是让单个 BFF 持有密钥,还是让每个委派节点通过 STS 获得自己的短期凭证。

如果你在平台目录里核对支持 OAuth 或 Agent 工具链的服务,或者要把远程工具接入自己的应用,可以把本文的检查项作为接入前的安全门槛。它们关注的是协议能力和实现边界,不是某个 API 入口的推荐排名。

DPoP 保护的对象与不保护的对象

Bearer 令牌的基本语义是“拿到令牌即可尝试使用”。OAuth 2.0 DPoP 标准增加了一层发送方约束:客户端生成公钥和私钥,在每次需要证明持钥的请求中发送由私钥签名的 JWT;授权服务器把令牌与公钥关联,后续验证方再检查请求中的证明是否对应同一把公钥。RFC 9449 还规定了证明中的请求方法、目标 URI 和唯一标识等字段,服务端可以据此检测把证明搬到另一个请求或重复发送的情况。

Google 的实现选择了更窄的保护面。官方 DPoP 指南要求在授权码交换时生成 P-256、ES256 密钥对,把公钥放进 JWT 头部,并在发往 oauth2.googleapis.com/token 的请求里加入 DPoP 头。刷新请求也要按同样方式生成证明。绑定成功后,Google API 的资源请求继续使用 Authorization: Bearer <access_token>,而不是向每个资源请求附加 DPoP 证明。

这个边界带来一个重要的架构结论:DPoP 降低的是“刷新令牌泄露后被离线重放”的风险,不是“访问令牌泄露后完全不能使用”。访问令牌仍然是短期 Bearer 凭证,日志、代理、浏览器扩展、错误追踪和工具输出仍不能暴露它。Google 的最佳实践还要求安全保存用户令牌、不要以明文传输,并在不再需要时撤销和删除。DPoP 是纵深防御,不是把令牌生命周期管理交给协议后就可以不管。

另一个边界是 DPoP 不等于客户端认证。RFC 9449 明确把它定义为证明持有密钥的机制,而不是替代 client_secret、工作负载身份或访问控制。客户端是否有权限访问某个 Scope,仍由 OAuth 授权和资源服务器策略决定;DPoP 只回答“这次请求是否持有绑定密钥”。

实现时必须保留的四个细节

生成并保存密钥对

Google 指南建议使用 EC P-256 密钥对,DPoP Proof JWT 的 JOSE 头部包括 typ: dpop+jwt、alg: ES256 和不含私钥参数的公钥 JWK。私钥必须与刷新令牌放在同一个受保护的信任边界内,但不能把私钥放进前端可读配置、普通日志或 Agent 消息。Google 随 DPoP 发布的实现技能进一步要求私钥不可导出,并区分 WebCrypto 的原始签名格式与某些旧运行时的 DER 格式;这类细节一旦处理错,代码可能在本地生成了 JWT,却无法被授权服务器验证。

在服务端应用中,可以让 BFF 为用户会话维护密钥句柄和刷新令牌引用。数据库只保存加密后的刷新令牌与密钥标识,签名操作交给受保护的密钥存储或运行时密钥边界。这里不能把“存入环境变量”当作密钥保护策略:环境变量适合注入配置,不能自动解决转储、日志、子进程和备份暴露问题。

对于纯前端 SPA,Google 官方指南给出的限制更直接:无密钥的客户端不能直接完成该 DPoP 流程,因为令牌端点需要机密客户端能力,同时浏览器对 DPoP-Nonce 响应头的读取还受到 CORS 约束。可行的结构是让 BFF 承担授权码交换和刷新,前端只拿自己的会话 Cookie 或应用层会话令牌,不接触 Google 刷新令牌。

每一次证明都要重新构造

DPoP Proof JWT 不是可以长期缓存的访问凭证。RFC 9449 要求每个请求使用唯一证明,至少包含 jti、htm 和 htu,并由客户端私钥签名。Google 的授权码交换示例把 jti 与授权码的 SHA-256 派生值关联;刷新请求则使用每次请求生成的随机 jti,同时带上当前有效的 nonce 和新的 iat。

因此,重试不能简单地把原始 HTTP 请求连同原始 DPoP 头再发一次。客户端需要在识别挑战后读取新 Nonce,重新生成证明,再发起一次请求。可以把这一逻辑封装成只负责令牌端点的函数,接口大致如下:

refreshToken(refreshToken):
  proof = createProof(method="POST", uri=tokenEndpoint, nonce=storedNonce)
  response = POST(tokenEndpoint, DPoP=proof, grant_type=refresh_token)

  if response.status == 400 and response.error == "use_dpop_nonce":
    storedNonce = response.headers["DPoP-Nonce"]
    proof = createProof(method="POST", uri=tokenEndpoint, nonce=storedNonce)
    return POST(tokenEndpoint, DPoP=proof, grant_type=refresh_token)

  return response

这里的“一次重试”是安全边界,不是性能优化。Google 的指南说明,授权码交换与刷新属于不同 OAuth 工作流,切换工作流时可能先收到 400 use_dpop_nonce,这表示服务端要求建立新的 Nonce 命名空间,不应被记录成上游服务故障。重试后仍失败,就应该把错误交给上层,而不是无限递归生成证明。

Nonce 要按客户端状态管理

Nonce 不是全局常量,也不是可以写进前端静态配置的版本号。它由授权服务器通过 DPoP-Nonce 响应头给出;客户端要把它放进下一次证明的 payload。授权码交换成功和刷新成功都可能返回新的 Nonce,客户端应更新对应工作流的状态。

在多用户服务中,Nonce 至少要按 OAuth 客户端和用户会话隔离;在并发刷新场景中,还需要避免两个请求拿到同一个旧刷新令牌和旧 Nonce 后同时提交。一个稳妥做法是为每个用户令牌记录建立刷新互斥,只有持有刷新锁的请求可以使用刷新令牌;其他请求等待新访问令牌或读取刷新结果。这样解决的是刷新令牌轮换与 Nonce 状态的一致性,不应靠“多重试几次”掩盖竞态。

访问令牌仍按 Bearer 处理

拿到访问令牌后,调用 Google API 的请求继续使用标准 Authorization: Bearer 头。中间件不要因为看到 DPoP 就擅自把 DPoP 头转发给所有资源 API,也不要把访问令牌和 DPoP Proof JWT 放入同一条 Agent 工具结果。工具适配层应只返回完成任务所需的最小业务数据,并在日志脱敏规则中同时屏蔽 access_token、refresh_token、DPoP 和 DPoP-Nonce。

多 Agent 和 MCP 场景该怎样拆信任边界

单 Agent 直接调用 Google API 时,模型本身不应该持有 OAuth 刷新令牌,更不应该拥有签名私钥。推荐把令牌端点放在 Agent 外的身份服务或 BFF 中:Agent 请求一个受限的业务动作,BFF 负责校验用户会话、刷新访问令牌并调用资源 API。这样模型只能提出工具参数,不能决定 Scope、重放刷新请求或读取长期凭证。

当 Agent A 把工作委派给 Agent B,B 不能为了“延续 A 的 DPoP 身份”而复制 A 的私钥。Google 官方博客明确指出,多 Agent 链路中的中间 Agent 无法代表上游私钥生成 DPoP 证明,共享私钥违背 DPoP 的核心设计。若每个 Agent 都需要自己的凭证,应使用 Google Cloud STS 做令牌委派,把上游身份交换成与下一跳密钥或工作负载绑定的短期令牌;具体 Scope、受众和生命周期仍要由服务端策略限制。

MCP 连接把这个问题放大了。一个远程 MCP Server 可能只是工具执行者,不应自动获得用户完整的 Google Scope。建议将 MCP Server 看成独立资源服务器:入口层校验调用方身份和受众,工具层按动作映射最小 Scope,令牌刷新留在受控的身份组件,审计记录工具名、用户会话、受众和结果状态,但不记录原始令牌。如果工具需要继续委派,应显式定义“谁为谁签发什么短期凭证”,不能用共享环境变量把刷新令牌塞进所有 Server。

这种拆分也能和站内的 MCP 工具错误排查文章互相参照:协议层的鉴权失败、资源 API 的 401、令牌端点的 400 use_dpop_nonce 和业务工具返回的权限错误,不是同一种错误,重试策略也不应相同。若你需要核对可接入的服务入口,可以查看站点目录,但不要把“支持 OAuth”理解成“支持 DPoP 刷新令牌”。

一份可落地的接入验收表

第一,确认客户端类型。若是纯 SPA,先决定是否引入 BFF;若是桌面或移动客户端,确认密钥能否进入平台安全存储;若是多租户服务,确认每个用户的令牌和密钥是否真正隔离。

第二,确认令牌端点实现。检查授权码交换和刷新请求是否都生成新的 DPoP Proof JWT,htu 是否精确指向令牌端点,htm 是否为 POST,jti 是否按请求更新,公钥 JWK 是否没有私钥参数。

第三,故意测试 Nonce 挑战。模拟或记录一次 400 use_dpop_nonce,确认客户端只提取响应头中的 DPoP-Nonce,重新签名并最多重试一次;不要把这个响应当作普通 400 直接重试原始请求,也不要用固定 Nonce。

第四,确认资源请求边界。检查访问令牌是否仍以 Bearer 方式发送,是否没有把 DPoP 证明扩散到不支持它的 API;同时检查网关、反向代理和日志系统是否会记录敏感头。

第五,确认委派路径。列出每个 Agent、MCP Server、BFF 和资源 API 的身份、受众、Scope、令牌类型与过期时间。任何需要共享私钥、复制刷新令牌或让模型读取令牌的设计,都应在上线前退回重做。

第六,确认撤销与恢复。Google 的 OAuth 最佳实践要求处理刷新令牌失效、撤销和删除;应用应能把失效状态传递到用户授权界面,而不是把所有失败都变成无穷重试。对于轮换或密钥丢失,还要有重新授权和清理旧密钥的路径。

参考来源