
你们公司的内部身份提供方已经在 Spring Authorization Server 1.x 上运行了两年。从理论上讲,升级到 Spring Boot 4 似乎是一项例行操作:更新物料清单(BOM)、修复已弃用的功能,然后发布即可。然而,当你打开迁移指南时,却发现所依赖的授权服务器已经不再是一个独立的项目——当你还在使用 1.x 版本时,它已经被合并到了 Spring Security 7.0 中。在当前的 7.1 版本线中,配合 Spring Boot 4.1 使用时,你一直推迟采用的两项 OAuth 2.1 功能——发送者受限令牌和真正的委托授权,现在已经位于同一个模块中,而且 Spring 已经发布了正式版(GA)等你启用。
这是 Spring Boot 4 迁移指南中未涉及的部分。这些指南涵盖了 Jackson 3、lambda DSL 以及包重定位,但仅止步于 OAuth 2.0。身份验证层最大的收获是 DPoP(RFC 9449)和令牌交换(RFC 8693),这两项功能首次在官方组件中达到生产就绪状态,对于运行自有身份提供方(IdP)的用户而言,这正是此次升级真正物有所值之处。本文将从服务器端进行详细讲解,涵盖已整合功能、启用 DPoP 后的变化、RFC 8693 令牌交换的端到端流程,以及为何值得同时启用这两项功能。本文假设你已经在运行 OAuth 2.0 / OIDC 服务器并且了解相关协议,因此不会从零开始重新讲解 OAuth。
Spring Boot 4 整合了什么功能
Spring 2025 年 11 月的发布序列分三个阶段推出:11 月 13 日发布 Spring Framework 7.0,11 月 17 日发布 Spring Security 7.0,11 月 20 日发布 Spring Boot 4.0。发布说明首先介绍了模块化、JSpecify 空值安全、API 版本控制以及 Jackson 3 基线。OAuth 服务器相关功能是这些模块的基础。上述变更已经在两个月前公布。
2025 年 9 月 11 日,Spring 团队宣布,Spring Authorization Server(该项目自 2020 年起成为独立项目,并于 2022 年 11 月进入正式发布阶段)将并入 Spring Security 7.0。独立的 1.5.x 分支是最后一个独立版本,此后新功能的开发工作将在 Spring Security 内进行。Spring Security 于 2026 年 6 月发布了 7.1.0 版本(与 Spring Boot 4.1 配套),并随后于 2026 年 8 月发布了 7.1.1 版本,因此该包的当前坐标为 org.springframework.security:spring-security-oauth2-authorization-server:7.1.1。整合工作本身发生在 7.0 版本中。这里的示例专门针对 7.1.1 版本,因为本文涉及的两个特定于版本的细节在该版本中均出现了:资源服务器的 .dPoP() 启用选项(7.1.0 新增)以及可插拔的 JTI 重放缓存(7.1.1 新增)。每个细节都会在其出现的位置进行说明。对于一直将“授权服务器”视为具有独立发布周期的单独依赖项的团队而言,这次整合无疑是一场重头戏。从今往后,他们只需要跟踪一个安全模块、一个版本以及一份发布说明即可。
这里有一个细节值得弄清楚,因为这是人们在这次迁移过程中最常犯的错误。Maven 的坐标并未改变。此前,作为一个独立的项目,Spring Authorization Server 是发布在 org.springframework.security:spring-security-oauth2-authorization-server 下,而 Spring Security 7 中合并后的模块使用了相同的 groupId 和 artifactId,只是版本号从 1.5.x 系列迁移到了 Spring Security 版本系列(7.0.x,现为 7.1.x)。如果你在某处看到需要重命名 spring-authorization-server 工件的说明,那么请注意,该工件从未存在过。只有当你通过 Boot 启动器(而非直接使用库)引入授权服务器时才需要进行一次重命名。在 Boot 4 中,该 starter 增加了 security- 前缀,因此 spring-boot-starter-oauth2-authorization-server 已经被弃用,取而代之的是 spring-boot-starter-security-oauth2-authorization-server,两者均已随 4.1.1 版本发布。除此之外,此次迁移仅涉及版本号的提升以及相同包的重定位。Jackson 的变动由 Spring Security 7 全量兼容。依赖坐标保持不变,无需替换。
Xml
通过迁移到 7.x 系列,你可以获得一个模块,其中包含 DPoP 签发、刷新令牌 DPoP 绑定以及令牌交换功能(与已在 Spring Security 6.5 中正式发布的资源服务器 DPoP 支持功能共存)。在此次整合之前,如果要将所有这些功能整合到一起,必须将特定的独立授权服务器版本与特定的 Spring Security 版本绑定,并且还得寄希望于两者的兼容性矩阵能够匹配。现在,只需要一个版本号就可以了。

如图 1 所示,独立的 Spring 授权服务器(1.5.x,上一代)已经整合到了 Spring Security 7.x 系列中,并且使用相同的 Maven 坐标,将 DPoP 和令牌交换合并成了一个模块。
只要要缴一次迁移税
在介绍任何新功能之前,Spring Security 7 首先需要进行一些基础的迁移调整,这些调整大多与 OAuth 无关:HttpSecurity 构建器中已经没有 and() 方法,Jackson 3 取代了 Jackson 2,此外还有几处 JSON Web Token (JWT) 解码器的默认设置发生了变化。Spring 官方的迁移指南"及 OAuth 2.0 的专有页面"均详细介绍了具体的操作机制;具体步骤见下文操作指南中的第 2 至 4 项。
以下两点该指南并未进行详细说明,因为它们仅适用于运行授权服务器,而非任何 Spring Security 应用程序。首先,typ header 的更改并不意味着放宽了验证标准。相反,这是 Spring 终于将 6.5 版本准备步骤中要求你手动添加的内容实现了自动化:validateTypes(false) 可以关闭 Nimbus 自身的 typ 检查,以便你可以自行注册 JwtTypeValidator.jwt();而在 7.0 版本中,这两个步骤都已经自动完成。实际的审核重点比“我的令牌还能通过验证吗?”这一问题更为具体。JwtTypeValidator.jwt() 要求 typ 必须为 JWT 或缺失。因此,服务器签发或接收到任何 typ 不同的令牌(例如 RFC 9068 中的 at+jwt 访问令牌类型),在默认情况下,现在都会验证失败,除非你注册了一个允许该类型的验证器。其次,如果你使用 Spring 的 Jackson 模块将 OAuth2Authorization 对象持久化到数据库中,那么这项注册就必须从 Jackson 2 的 SecurityJackson2Modules 迁移到新包 org.springframework.security.jackson 中的 SecurityJacksonModules,否则之前持久化的授权状态在反序列化时将出现错误。
已经和 Spring Security 6.5 弃用功能保持同步的团队,可以在接触任何新的 OAuth 2.1 功能之前,将基础迁移规划为大约一个冲刺周期的工作量。而落后几个版本的团队,则应该将基础迁移和功能采用视为两项独立的工作。
端到端 DPoP
DPoP(RFC 9449)将访问令牌与客户端持有的密钥绑定。客户端生成一对非对称密钥,并在每次请求时签署一个短效的证明 JWT。授权服务器在令牌端点验证该证明,并将签发的访问令牌与该证明的公钥绑定。随后,资源服务器将拒绝接受该令牌,除非其随附来自同一密钥的新鲜证明。如果没有私钥,被盗的令牌将毫无用处。这正是该机制的核心所在,我们另一篇与本文配套的浏览器端文章就是这个主题——该文章探讨了浏览器应将该密钥安全地存储在何处这一难题。而本文关注的则是网络另一端的情况,即当证明到达时,服务器将如何处理。
该证明的具体规范见 RFC 9449 第 4.2 节。其 header 包含 typ: dpop+jwt、非对称算法标识,以及以内联 JWK 形式存在的客户端公钥。有效载荷通过 htm(HTTP 方法)、htu(目标 URI,已去除查询字符串和片段)、iat 以及一个唯一的 jti,将证明与请求绑定在一起。当证明随访问令牌一起发送至资源服务器时,它还会携带该令牌的 base64url 编码的 SHA-256 哈希值。

令牌发放:两项默认需要你做出的关键决策
在授权服务器端,Spring 会为你自动配置证明验证机制。DPoPProofVerifier 及其配套的 DPoPProofJwtDecoderFactory 会执行 RFC 9449 第 4.3 节规定的检查:格式正确的 JWT、类型为 dpop+jwt 的 typ、非空的非对称算法、可通过嵌入式 jwk 验证的签名,以及与令牌请求匹配的 htm/htu。有两件事看似需要你做决定,实则不然。你真正需要做的,只是自行构建 nonce 挑战(如果确实需要的话)以及确定重放缓存的范围。
cnf.jkt 绑定框架已自动处理。访问令牌必须携带一个 cnf 声明,其 jkt 成员是证明密钥的 JWK SHA-256 Thumbprint(RFC 7638),使用了 base64url 编码。当 DPoP 处于启用状态时,Spring 会自动填充此密钥;唯一需要注意的是,某些令牌定制器可能会意外覆盖 cnf 字段。资源服务器后续会与该指纹进行比对,而非与公钥或证书哈希值进行比对。
JSON { "sub": "service-a", "aud": "https://orders.internal", "cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" } }
首先要考虑的是 nonce 挑战机制,Spring 完全未实现该机制。RFC 9449 第 8 节允许授权服务器要求在证明中包含由服务器选择的 nonce,以防客户端预先生成证明;从第 9 节可以看到,资源服务器采用了相应的机制。当服务器要求客户端提供其未提供的 nonce 时,服务器将返回错误:use_dpop_nonce 以及 DPoP-Nonce 响应头,授权服务器返回 400 状态码,资源服务器返回 401 状态码。截至 Spring Security 7.1.1 版本,Spring 的 DPoP 代码在两端均未实现上述功能:既不发出 DPoP-Nonce 头,也不处理 use_dpop_nonce 请求,更没有验证器来检查证明中可选的 nonce 声明。Nonce 挑战在 RFC 中属于“SHOULD”(建议),而非“MUST”(必须),这大概就是它未被实现的原因;但如果你的威胁模型需要它,那么你就必须自己构建整个机制:签发 nonce、返回挑战响应、存储已签发的 nonce,并根据 nonce 验证声明。
框架已处理公共客户端刷新令牌绑定
RFC 9449 第 5 节规定,当公共客户端在令牌端点出示 DPoP 证明时,其收到的刷新令牌必须与该密钥绑定,而且后续的刷新操作必须证明使用的是同一密钥。Spring Authorization Server 在 1.5.0-RC1 里程碑版本(2025 年 4 月)中自动添加了该密钥,并将其延续到了 7.x 系列,因此该功能无需手动启用。而机密客户端依赖现有的客户端凭证绑定,因此这种做法对单页应用(SPA)和原生应用最为重要。
其次是重放缓存。RFC 9449 第 11.1 节要求,在可接受的时间窗口内,若 jti 已经被检测到,就应该拒绝该证明。在 Spring Security 7.0.x 和 7.1.0 版本中,该检查是硬编码的:一个包含 1000 个条目的固定内存缓存被封装在 DPoPProofJwtDecoderFactory 内部,而且无法替换。自 Spring Security 6.5.12 / 7.1.1 起,该功能改为由可插拔的 DPoPProofReplayValidator 负责。在默认情况下,该验证器会连接其自身的 InMemoryCache(10 万条记录,每个键有上限)。该默认实现的 Javadoc 明确指出,它“仅在单节点环境中有效”,并列举了 Redis 和 Hazelcast 作为集群部署时所需提供的分布式缓存类型。
验证功能已经在资源服务器上正式上线
在资源服务器端,Spring 7 移除了大部分的自定义代码。与 DPoP 绑定的访问令牌验证功能是在 Spring Security 6.5.0 的里程碑版本中引入的,并在 6.5.0 版本(2025 年 5 月)中正式发布。这里有一个细节值得注意。在 Spring Security 7.0 中,只要类路径中包含 DPoP 类,该功能就会自动启用,不需通过 DSL 进行配置。Spring Security 7.1 在资源服务器 DSL 中添加了显式的 dPoP(Customizer) 方法(自 7.1 起),使该功能支持按需启用并可自定义;在 7.1 版本中,调用 .dPoP() 即可启用该功能。在升级时,这属于真正的行为变更,而不仅仅是一个新选项:如果应用程序(包括完全基于 Spring Boot 默认资源服务器自动配置构建、从未调用过新钩子的应用程序)依赖 6.5 或 7.0 版本中自动应用的验证功能,那么一旦升级到 7.1 版本,就会在未显式添加 .dPoP() 之前,悄无声息地停止强制执行 DPoP。DPoP 绑定令牌应通过 DPoP 身份验证方案发送,而非 Bearer 方式。将其作为 Bearer 发送是一种常见但错误的捷径。Spring 的 DPoPAuthenticationProvider 会负责处理此操作。
有一个命名陷阱值得特别指出一下,因为它在代码审查时会引发问题。资源服务器提供程序是 DPoPAuthenticationProvider,没有 OAuth2 前缀,这与下一节中的 OAuth2TokenExchangeAuthenticationProvider 不同。转换器和提供程序也位于不同的包中。DPoPAuthenticationConverter 位于 ...resource.web.authentication 包中,而 DPoPAuthenticationProvider 和 DPoPAuthenticationToken 则位于 ...resource.authentication 包中,没有 web。
Java // 资源服务器:接受 DPoP-bound 访问令牌 @Bean public SecurityFilterChain resourceServer(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) // 验证访问令牌 .dPoP(Customizer.withDefaults()) // 验证证明:签名、htm/htu、ath、cnf.jkt ); return http.build(); }
配置了 .dPoP() 后,资源服务器会验证证明签名,根据传入的请求确认 htm/htu,检查证明的路径是否等于所提供令牌的哈希值,并确认证明密钥的指纹是否等于令牌的 cnf.jkt。在 Spring Security 6.5 之前,这需要手动编写过滤器代码。而现在,只需要在 DSL 中写一行代码即可。
无需定制基础设施的令牌交换
大多数内部身份提供方(IdP)都采用某种形式的“服务账户切换”机制。当服务 A 需要代表用户调用服务 B 时,它会调用第二组凭据,通过侧信道生成一个新令牌,或者将用户自身的令牌传递给下游服务,并寄希望于接收方对身份验证的要求较为宽松。RFC 8693 用标准授权机制取代了这种做法。
令牌交换功能已经集成到 Spring Authorization Server 1.3.0-M3(2024 年 3 月)中,位于 7.x 模块。其授权类型为 urn:ietf:params:oauth:grant-type:token-exchange。请求中包含一个 subject_token(代表被代理方),以及一个可选的 actor_token(代表执行方),同时还包含调用方希望可以在请求返回时获得的受众、资源和范围。
Http POST /oauth2/token HTTP/1.1 Content-Type: application/x-www-form-urlencoded DPoP: eyJ... # the edge service's proof, so the issued token re-binds to its key grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token=eyJ... # the incoming user token, on whose behalf the call is made &subject_token_type=urn:ietf:params:oauth:token-type:access_token &actor_token=eyJ... # the edge service's own token; this is what produces the act claim &actor_token_type=urn:ietf:params:oauth:token-type:access_token &audience=https://orders.internal &scope=orders:read
对于 RFC 8693 1.1 节所区分的两种语义而言,令牌交换至关重要。模拟会签发一个看上去像是目标主体的令牌,而发起操作的一方身份会被隐藏。委托则保留了两种身份,签发的令牌既包含目标主体,也包含一个标识行动者的“行为(行动者)”声明。最终获得哪种令牌由一个参数决定。仅发送 subject_token,Spring 的 OAuth2TokenExchangeAuthenticationProvider 就会签发一个不包含 act 声明的模拟令牌。如果同时发送 actor_token,那么该提供程序就会构建一个委托令牌,其中 act 声明源自 actor_token 而非经过身份验证的客户端。对于日后需要审计的任何情况以及服务间调用,委托模式正是你所需要的。
RFC 8693 第 4.1 节介绍了通过嵌套 act 声明来表示多跳链,其中最外层为当前行动者,而每个嵌套层则代表一个先前的行动者。这些嵌套条目源自 subject 令牌。如果网关在之前的交互中已经在该令牌上留下了 act 声明,而边缘服务现在将其作为 subject_token 呈现,那么 Spring 会将先前的行动者作为嵌套条目传递下去。因此,在这个例子里,作为行动者令牌所有者的 edge-service 就成了当前行动者,而 gateway-service 作为先前行动者嵌套其中。
JSON { "sub": "user-123", "aud": "https://orders.internal", "scope": "orders:read", "act": { "sub": "edge-service", "act": { "sub": "gateway-service" } } }
在 Spring 侧,该授权类型由 OAuth2TokenExchangeAuthenticationProvider 处理,它是通过授权服务器配置器进行注册的。客户端默认并没有启用这个授权。调用客户端的 RegisteredClient 必须自己声明,否则身份提供方将以 unsupported_grant_type 为由拒绝该请求。
Java RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("edge-service") // ……客户端身份验证、权限范围…… .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) .build();
声明本身由框架负责处理,它基于上文所描述的行动者令牌构建而成,因此无需手动组装。你需要做的只是通过移除作用域或切换受众来缩小生成的令牌范围,这正是 OAuth2TokenCustomizer
Java @Bean public OAuth2TokenCustomizer
有一条原则值得牢记:在令牌交换过程中,你应当缩小权限范围,而绝不能扩大它。如果某个令牌交换端点被利用来访问超出该令牌所涵盖范围的权限,这属于权限提升漏洞,而非设计功能。
既需要 DPoP 又需要令牌交换的模式
这两项功能单独使用都很有用。而结合起来,可以解决单凭其中任何一项都无法解决的问题:即在服务链中传播受限权限,同时又不向每个节点分发可能被重放的 bearer 令牌。
设想有一个边缘服务,它从客户端接收了一个与 DPoP 绑定的访问令牌。该服务需要代表用户调用订单服务,但不应将用户的令牌原封不动地转发出去。该令牌的受众是边缘服务,而转发 bearer 副本会使订单服务获得可以在任何地方重放的信息。因此,边缘服务会执行令牌交换。它在交换请求中将用户的令牌作为 subject_token 提交,将自身的令牌作为 actor_token 提交,并附上 DPoP 证明,随后请求一个作用域为 orders:read、受众为 https://orders.internal" 的令牌。授权服务器会签发一个范围受限的令牌,在 actor_token 的 act 声明中记录边缘服务,并且由于边缘服务发送了证明,所以它会通过 cnf.jkt 将新令牌绑定到边缘服务自身的密钥上。

结果很明确。订单服务可以验证接收到的令牌,其发送者仅限于该服务实际通信的调用方,作用域严格限定于该调用所需的内容,并附有关于“谁委托给谁”的审计日志。任何中间节点都不会持有其未调用服务的可重放凭证。这正是现代服务网格和代理系统一直试图通过临时头信息传递来拙劣复刻的架构模式。RFC 8693 与 RFC 9449 是标准轨迹版本,而 Spring Security 7 是首个将这两部分整合发布的官方 Spring 框架。
这里确实有一个需要注意的地方。范围限制需要由你来执行。Spring 为你提供了授权和自定义钩子,但它不会阻止你编写一个会扩大范围或跳过受众切换的交换器。该模式的安全性取决于你编写的 OAuth2TokenCustomizer。这段代码值得单独进行测试。
迁移手册
有两个入手点适用于大多数团队。第一个是基于 Spring Boot 4.1 的 Greenfield 方案。新的身份提供方(IdP)从 7.x 模块开始构建,从第一天起就可以使用 DPoP 和令牌交换功能。这主要是配置工作,而非迁移:注册客户端、确定 nonce 策略、编写用于权限收敛的定制器,并在每个资源服务器上启用 .dPoP()。从首次提交开始,你就应该将 OAuth2TokenCustomizer 视为安全关键代码。
第二个是现有的独立运行的 Authorization Server 1.5.x。这是对你已使用的版本进行升级,而非平台迁移。操作步骤如下:
迁移至 Spring Boot 4.1 / Spring Security 7.1 BOM。将 spring-security-oauth2-authorization-server 从 1.5.x 升级至 7.1.x,groupId 和 artifactId 保持不变。将任何剩余的 and() 风格过滤器链转换为 lambda DSL。检查 JwtDecoder 配置中的 validateTypes(false),并确认新的 JwtTypeValidator 默认接受你的令牌。更新用于持久化授权状态的 Jackson 模块注册,将 SecurityJackson2Modules 改为 SecurityJacksonModules。只有在基线迁移通过后,才作为一项单独的变更,为新客户端注册启用 DPoP 并部署令牌交换授权流程。
生产环境部署有其自身的决策流程。这些决策将功能开关转化为实际的部署。在切换过程中,应保持签发 URL 的稳定性。在授权服务器元数据中公布 DPoP 及令牌交换授权机制,以便客户端能够发现它们。在开始签发与 DPoP 绑定的令牌之前,应该先在资源服务器上实现 .dPoP() 支持。这样,当第一个绑定令牌到达时,验证器已经做好准备。规划一个双栈过渡期,在此期间,随着客户端的迁移,bearer 令牌和 DPoP 绑定令牌将并存。按客户端注册逐个启用 DPoP,而非全局启用。这样,现有的客户端群可以继续正常运行,而新注册的客户端则可以选择加入。
正是这些元数据让客户端能够发现所有这些功能。授权服务器的 /.well-known/oauth-authorization-server 文档应该同时声明这两项能力:其支持的 DPoP 签名算法,以及 grant-type 列表中包含的令牌交换授权类型。
JSON { "issuer": "https://idp.internal", "grant_types_supported": [ "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange" ], "dpop_signing_alg_values_supported": ["ES256", "RS256"] }
未来展望
请特别注意 nonce 缺失问题。RFC 将其标记为“SHOULD”(建议),而非“MUST”(必须),而且截至本文撰写之时,Spring Security 的问题跟踪器中尚没有人就此提出公开的功能请求。因此,希望实现该功能的身份提供方需要自行开发,应该提前做好相关规划,而非等待未来版本的发布。
更持久的趋势则超越了单个版本的范畴。多跳服务调用以及跨信任边界、日益增多的自主代理链,不仅需要凭证持有证明,还需经过审计且权限逐步收窄的委托链,而不仅仅是凭证令牌和作用域检查。RFC 8693 中嵌套的 act 声明已经对这种传递链进行了建模。随着该架构的普及,本文所涵盖的组合——DPoP 绑定加令牌交换——看起来已经不像是 Spring Security 7 中的功能组合,而更像是身份提供方的通用默认标准。
原文链接:https://www.infoq.com/articles/spring-security-7-server-side-dpop-token-exchange/"

