土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径

文章导航

分类入口
architecturesecurity
标签入口
#OAuth#OAuth2#PKCE#DPoP#PAR#authorization#security

目录

某团队维护的单页应用(SPA,Single Page Application)在一次例行安全审计中被红队给出高危结论:用户登录后,access_token 以 URL fragment(#access_token=...)形式直接落在浏览器地址栏。紧接着被发现的链路有三条——浏览器历史记录完整保留了该 token,前端曾经把 window.location.href 写入过前端日志收集系统,某个第三方监控 SDK 还会把完整 URL 连同错误上下文一起上报。审计方给出的结论很直接:只要攻破前述任何一环就能拿到一个几十分钟有效的访问凭证,而 SPA 用的是 OAuth 2.0 的 implicit grant flow(隐式授权流程,RFC 6749 §4.2),这正是 OAuth 2.1 决心废弃的历史遗产。

OAuth 2.1 + PKCE 授权码流程

这次事故不是个例。把 token 从 URL 里赶走、把 public client 的授权码拦截风险堵住、把松散的 redirect 匹配收紧——这三件事合起来,构成了 OAuth 2.1 草案(draft-ietf-oauth-v2-1)最重要的一次收敛,而 PKCE(Proof Key for Code Exchange,RFC 7636)则是这次收敛的核心密码学机制。本文从这条主路径出发,系统讨论 OAuth 2.1 的变更、PKCE 的数学原理、完整的授权码 + PKCE 流程参数、SPA 与移动端的必选理由,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面。更基础的概念性介绍可参考 OAuth 2.0 入门PKCE 基础,宏观的认证架构请见 身份与认证架构总览

一、从 OAuth 2.0 到 OAuth 2.1:一次迟到的收敛

1.1 为什么需要 OAuth 2.1

OAuth 2.0 自 2012 年 RFC 6749 发布以来,实际上是一个”菜单式”的授权框架:定义了四种 grant type、两类 client(public 与 confidential)、多种 token 传递方式,把大量安全决策留给实现者。十余年的工业实践暴露出两个问题。第一,RFC 6749 本身只描述机制,真正的安全实践散落在十几份 BCP(Best Current Practice)与扩展 RFC 里,例如 OAuth 2.0 Security Best Current Practice(draft-ietf-oauth-security-topics)、OAuth 2.0 for Native Apps(RFC 8252)、OAuth 2.0 for Browser-Based Apps(draft-ietf-oauth-browser-based-apps)——开发者很难把它们全部读完、正确落地。第二,早期 RFC 6749 允许的某些模式(implicit、password grant)在现代威胁模型下已经是净负收益,继续放在”标准选项”里会诱导错误实现。

OAuth 2.1 的设计目标非常克制:不引入新协议语义,只做三件事——合并 BCP 中已成定论的要求、删除被证明不安全的 grant、把原本”建议”改成”必须”。它是一次有意为之的收敛,而不是 3.0 式的重构。

1.2 废弃 implicit grant

implicit grant 的工作方式是:客户端在 /authorize 请求中指定 response_type=token,授权服务器(AS,Authorization Server)直接在重定向 URL 的 fragment 中返回 access_token。RFC 6749 当年设计它是为了解决 SPA 拿不到授权码的问题——SPA 没有后端,无法安全保存 client_secret 去换 token,于是干脆让 AS 直接返回 token。

问题在于 token 落在 URL 上之后,泄露路径众多:浏览器历史记录、前端埋点与错误上报、浏览器扩展、共享设备上的截屏/录屏、以及任何会读取 window.location 的第三方脚本。更糟的是,implicit 流程默认无法签发 refresh_token(因为无法证明调用方身份),导致实现者要么短 token 频繁跳转体验糟糕,要么把 token 寿命拉得很长使泄露后果更严重。

OAuth 2.1 直接删除了 implicit grant。替代方案是所有 public client(包括 SPA)统一走授权码流程 + PKCE。PKCE 让拿不到 client_secret 的客户端也能安全地用授权码换 token,详见第二节。

1.3 废弃 Resource Owner Password Credentials(ROPC)

ROPC 让用户把用户名密码直接交给客户端,客户端再拿这对凭证去 /token 换 access_token。它的存在理由是”迁移旧系统”——一些 legacy 客户端习惯了用密码登录,直接替换成浏览器跳转体验断裂。但代价非常大:

OAuth 2.1 也删除了 ROPC。迁移路径是改为 device authorization grant(RFC 8628,适合无浏览器环境)或引导到浏览器走授权码 + PKCE。

1.4 PKCE 对所有 public client 强制

RFC 7636 最初把 PKCE 定位为”移动 App 推荐使用”,原因是移动端的自定义 URL scheme 容易被劫持。OAuth 2.1 把 PKCE 的适用范围扩展到所有 public client——包括 SPA、桌面应用、IoT 设备、CLI 工具——并且从”推荐”升级为”必须”。更进一步,Security BCP 建议连 confidential client 也开启 PKCE,因为它免费提供了一层防御,不依赖 client_secret 的保密性。

这一条是 OAuth 2.1 里最具现实影响的改动:几乎所有新 SDK、新 AS 实现都默认开启 PKCE 校验;如果客户端没有附带 code_challenge,AS 会直接拒绝。

1.5 redirect_uri 精确匹配

RFC 6749 允许 AS 对注册的 redirect_uri 做前缀匹配或模式匹配,例如客户端注册 https://app.example.com/ 就能接受 https://app.example.com/callbackhttps://app.example.com/oauth/cb 等任意子路径。实际攻击中这被滥用为:利用开放重定向(open redirect)漏洞把授权码带到攻击者控制的页面,或者利用子路径上的 XSS 把 URL 里的 code 读走。

OAuth 2.1 要求 redirect_uri 精确字符串匹配,包含大小写与 query string。与此同时,OAuth 回调地址本身就不应包含 fragment。客户端在注册时必须把所有合法回调地址完整枚举,AS 在比对时不允许任何通配、模板或字符串拼接。这对开发体验有少量摩擦,但杜绝了一整类攻击。

1.6 Bearer token 不得出现在 URL query string 里

RFC 6750 曾经列出三种 access_token 的传递方式:Authorization header、请求体表单字段、URL query string(?access_token=...)。第三种在日志、Referer、代理缓存里泄露风险最高。OAuth 2.1 明确禁止 URL query string 方式,只允许 Authorization header;请求体方式被限制在”不能使用 header 的极少数场景”。

1.7 refresh_token rotation

refresh_token 是长寿命凭证,一旦泄露后果比 access_token 严重得多。OAuth 2.1 建议对 public client 启用 rotation:每次用 refresh_token 换新 access_token 时,AS 同时发放一个新的 refresh_token 并使旧的立即失效。如果攻击者盗用了某个 refresh_token 并先于合法客户端使用,下次合法客户端再来兑换时旧 token 已经失效,AS 可据此触发异常——实际实现(如 Auth0、Okta)通常会直接撤销整条 token chain。

1.8 其他细节

1.9 对现有客户端的迁移成本

OAuth 2.1 并非破坏性协议变化——所有”仍被支持”的流程都是 OAuth 2.0 已有的,只是被选择性收敛。客户端迁移有三条主线:

对 AS 运营方而言,最关键的是”关掉旧开关”的节奏。把 implicit 和 password 从”默认允许”改成”默认禁止、按客户端显式申请豁免”;豁免项加观测与到期时间;在三到六个月窗口里推动客户端迁移完成。

下面这张表把主要变化列一下:

方面 OAuth 2.0 OAuth 2.1
implicit grant 允许 废弃
password grant 允许 废弃
PKCE 移动端推荐 所有 public client 必须
redirect_uri 前缀 / 模式匹配 精确匹配
URL query 传 token 允许 禁止
refresh_token rotation 可选 推荐(public client)
device flow 扩展 RFC 8628 纳入主文档

二、PKCE 的密码学原理

2.1 为什么 SPA 和移动端需要 PKCE

授权码流程的经典安全假设是:客户端有一个保密的 client_secret,AS 通过 client_id + client_secret 验证调用方身份,第三方即使截获 code 也没法兑换 token。这个假设在 confidential client(有后端的 Web 应用、后台服务)上成立。

public client 不成立。SPA 的所有代码都在浏览器里,任何嵌入的密钥都能被前端调试器读出来;移动 App 装到用户设备上,二进制里的常量可以被逆向;桌面客户端、CLI 工具同样如此。结论是:public client 必须假设自己没有任何秘密——任何”客户端 own”的凭证都可能被攻击者拿到。

于是问题变成:没有长期密钥的客户端怎么证明”这次来兑换 token 的人确实是当初发起授权的人”?PKCE 的答案是每次请求动态生成一对一次性的”挑战 / 答案”。

2.2 code_verifier 与 code_challenge 的构造

PKCE 定义了两个关键字段。

AS 在 /authorize 阶段把 code_challengecode_challenge_method 与该次 code 绑定持久化;在 /token 阶段客户端提交 code_verifier,AS 重新计算 BASE64URL-ENCODE(SHA-256(verifier)) 与当初存下的 code_challenge 比较,一致才签发 token。

2.3 S256 与 plain:为什么不能用 plain

RFC 7636 同时定义了 plain 方法——直接把 verifier 当作 challenge 传。OAuth 2.1 禁止 plain,原因是:

plain 模式下 code_challenge == code_verifier,也就是授权阶段传给 AS 的值和兑换阶段传给 AS 的值完全相同。如果攻击者能截获 /authorize 请求里的 challenge(它出现在 URL 里、会经过浏览器、日志、Referer),就等于拿到了 verifier,后面再截获 code 即可兑换 token。PKCE 的保护直接退化为零。

S256 的保护依赖 SHA-256 的单向性:即便攻击者完整截获 challenge,也无法从中倒推出 verifier。而 verifier 只在 /token 这次 TLS 请求的 body 里短暂出现一次,截获窗口极小。

OAuth 2.1 规范里把 plain 列为 MUST NOT support,新客户端、新 AS 都不应实现。

2.4 完整计算示例(Go)

下面是一段生产级风格的 Go 代码,演示 verifier/challenge 的生成:

package pkce

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
)

type Pair struct {
    Verifier  string
    Challenge string
    Method    string
}

func New() (*Pair, error) {
    // 32 字节随机 = 256 bit 熵;base64url 后约 43 字符,符合最小长度
    buf := make([]byte, 32)
    if _, err := rand.Read(buf); err != nil {
        return nil, fmt.Errorf("pkce: rand: %w", err)
    }
    verifier := base64.RawURLEncoding.EncodeToString(buf)

    sum := sha256.Sum256([]byte(verifier))
    challenge := base64.RawURLEncoding.EncodeToString(sum[:])

    return &Pair{
        Verifier:  verifier,
        Challenge: challenge,
        Method:    "S256",
    }, nil
}

一次真实运行结果(示意):

verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

把 verifier 喂进 sha256sum | xxd | base64 自行验证也是一样的结果——没有密钥、没有盐,完全是公开可复算的函数。安全性完全来自 verifier 本身的熵和一次性。

2.5 PKCE 为什么能防止授权码拦截

把攻击模型写清楚。授权码拦截攻击(Authorization Code Interception Attack,RFC 7636 §1 给出完整分析)的典型路径是:

  1. 合法客户端 C 发起授权:/authorize?... redirect_uri=customscheme://cb
  2. 用户在浏览器登录,AS 302 回 customscheme://cb?code=abc&state=xyz
  3. 在手机上,这个 custom scheme 可能被恶意 App M 注册了同样的 scheme——系统把 code 交给 M;
  4. 如果没有 PKCE,M 就可以直接带着 code 去 /token 换 access_token 和 refresh_token。

加上 PKCE 后,流程第 1 步 C 已经把 code_challenge = S256(verifier) 发给了 AS,AS 把 challenge 和 code 绑定;第 4 步 M 带着截获的 code 去兑换,但 M 没有 verifier(verifier 只在 C 的内存里,没通过任何外部通道传输),无论提交什么值 AS 都会算出不同的哈希,校验失败拒绝兑换。

这条保护的关键前提是 verifier 不被泄露。对 SPA 来说 verifier 放在内存或 sessionStorage(不是 localStorage,防止跨标签泄露);对移动端来说放在内存或 Keychain / Keystore;对桌面 CLI 来说放在进程变量。它的寿命很短——只在本次授权到 token 兑换之间存在,兑换完立刻销毁。

三、授权码 + PKCE 完整流程与参数

下面给出一次完整的授权码 + PKCE 交互,所有字段尽可能贴近真实请求。假设 AS 是 https://as.example.com,client_id 是 spa-demo,redirect_uri 是 https://app.example.com/callback

3.0 完整时序

在钻进参数之前先把时间线摆出来。客户端、浏览器、授权服务器、资源服务器四个角色的十来次交互,可以分成三个阶段:

  1. 前奏:客户端生成 verifier/challenge、决定 scope、发起浏览器跳转;这一步只涉及客户端和浏览器,AS 还没参与。
  2. 授权:浏览器把用户带到 AS,AS 执行登录、MFA、风控、同意页,最后 302 回带 code 的回调;AS 内部可能还有 SSO、passkey、风险引擎等多级子流程。
  3. 兑换与使用:客户端收到 code,在后台通过 TLS 直连 AS 兑换 token;拿到 token 之后对资源服务器发起实际 API 调用。整个 token 的寿命周期从这一刻开始计算。

把这个时序记在心里,下面每个请求/响应的字段作用就容易对应。

3.1 /authorize 请求

客户端先生成 verifier/challenge,再构造浏览器跳转:

GET /authorize
  ?response_type=code
  &client_id=spa-demo
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email%20orders.read
  &state=af0ifjsldkj
  &nonce=n-0S6_WzA2Mj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: as.example.com

关键参数解释:

3.2 /authorize 响应(用户同意后)

AS 渲染登录页、用户登录、用户同意授权后,返回 302:

HTTP/1.1 302 Found
Location: https://app.example.com/callback
  ?code=SplxlOBeZQQYbYS6WxSbIA
  &state=af0ifjsldkj

客户端(前端代码)首先校验 state 是否等于发起时的值,然后把 code 连同内存里的 code_verifier 一起提交到 /token

3.3 /token 请求

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=spa-demo
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

注意 public client 没有 client_secret,但必须提交 client_id(便于 AS 审计和限流)和 code_verifierredirect_uri 必须与当初 /authorize 里的值完全相同,AS 会二次校验以防 mix-up。

3.4 /token 响应

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "8xLOxBtZp8",
  "scope": "openid profile email orders.read",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA..."
}

AS 在这一步执行三件事:

  1. 检查 code 存在且未被使用(单次消费),校验过期时间(典型 60 秒);
  2. 取出与 code 绑定的 code_challenge,与 S256(code_verifier) 比较;
  3. 校验 client_idredirect_uri 与授权阶段一致。

任何一步失败,返回 400 invalid_grant,并且该 code 立即作废(防止爆破 verifier)。

3.5 调用受保护 API

GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

资源服务器(Resource Server,RS)校验 JWT 签名、issuer、audience、过期时间后放行。JWT 的签名校验与 JWKS 刷新逻辑本文不展开。

3.6 refresh_token 刷新

access_token 过期后:

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=spa-demo
&scope=openid profile email orders.read

如果开启了 rotation,响应里会返回新的 refresh_token,旧的立即失效;如果同一 refresh_token 被使用两次,AS 应撤销整个 token chain。

3.7 AS 侧的服务端校验伪代码

一次完整的 /token 端点处理大致如下:

func handleToken(w http.ResponseWriter, r *http.Request) {
    // 1. 解析 grant_type
    if r.FormValue("grant_type") != "authorization_code" {
        writeErr(w, "unsupported_grant_type")
        return
    }

    // 2. 原子地取出并删除 code(防止并发重复兑换)
    codeStr := r.FormValue("code")
    rec, err := store.ConsumeCode(r.Context(), codeStr)
    if err != nil {
        writeErr(w, "invalid_grant")
        return
    }

    // 3. 校验客户端
    clientID := r.FormValue("client_id")
    if rec.ClientID != clientID {
        writeErr(w, "invalid_grant")
        return
    }

    // 4. 精确匹配 redirect_uri
    if r.FormValue("redirect_uri") != rec.RedirectURI {
        writeErr(w, "invalid_grant")
        return
    }

    // 5. 校验 PKCE
    verifier := r.FormValue("code_verifier")
    if verifier == "" {
        writeErr(w, "invalid_grant")
        return
    }
    sum := sha256.Sum256([]byte(verifier))
    challenge := base64.RawURLEncoding.EncodeToString(sum[:])
    if subtle.ConstantTimeCompare([]byte(challenge), []byte(rec.CodeChallenge)) != 1 {
        writeErr(w, "invalid_grant")
        // 安全敏感实现在此刻撤销同链上的 refresh_token
        return
    }

    // 6. 签发 token
    issueTokens(w, rec)
}

两点值得强调:code 的消费必须原子(上面用单个 ConsumeCode 调用);挑战比对必须用常量时间比较,防止时间旁路——虽然 SHA-256 输出已经把 verifier 打散,但 PKCE 的一些扩展模式允许对称比较,养成习惯没有坏处。

四、浏览器与移动端落地

4.1 SPA 为什么没有别的选项

SPA 的约束可以归纳成三条铁律:

  1. 所有代码对用户可见,不能持有任何长期秘密;
  2. 没有后端会话存储,必须在浏览器里管理状态;
  3. 浏览器环境充斥着第三方脚本、扩展、cross-origin 窗口,任何暴露在 URL 或 storage 中的敏感值都要假设会泄露。

PKCE 恰好匹配这三条。它不要求客户端有 client_secret;verifier 可以短暂放在 sessionStorage(和当前标签绑定,关标签即销毁)或内存变量;challenge 即使泄露也无法独自完成兑换。这就是为什么 OAuth 2.0 for Browser-Based Apps BCP 和 OAuth 2.1 都把”SPA 用授权码 + PKCE”列为唯一推荐方案。

浏览器端还有两个额外最佳实践:

4.2 移动端的自定义 scheme 劫持

iOS / Android 通过 custom URL scheme 接收 OAuth 回调,例如 com.example.app://oauth/cb。问题是操作系统并不能保证 scheme 唯一,任何 App 都可以声明同一个 scheme,系统在多个声明者之间选择的规则不一致(Android 弹选择器、iOS 取最早安装的)。恶意 App 声明一个和目标 App 相同的 scheme,就可能截获回调里的 code。

对策有两个层次:

RFC 8252(OAuth 2.0 for Native Apps)同时强制两件事:授权必须在系统浏览器(或 In-App Browser Tab / ASWebAuthenticationSession)里完成,不允许用普通 WebView;客户端必须使用 PKCE。两条合起来才是完整方案。

4.3 AS 侧如何强制 PKCE

在 AS 实现上,强制 PKCE 意味着:

主流实现(Keycloak、Auth0、Okta、Azure AD、Hydra)都支持 per-client 的 PKCE enforcement 开关,推荐全局打开。

在真正实现时,浏览器侧通常有两种架构:

下面这段代码只演示纯浏览器 SPA的最小实现,用来说明 PKCE 的关键状态如何保存与销毁;如果你采用 BFF 架构,浏览器并不会直接调用 /token 端点。

4.4 纯浏览器 SPA 的最小实现(TypeScript)

用 Web Crypto API 可以在浏览器里完成 PKCE 全流程,无需任何第三方库:

async function startLogin() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = base64url(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
  );
  sessionStorage.setItem("pkce_verifier", verifier);
  const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
  sessionStorage.setItem("oauth_state", state);

  const url = new URL("https://as.example.com/authorize");
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: "spa-demo",
    redirect_uri: "https://app.example.com/callback",
    scope: "openid profile email orders.read",
    state,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString();
  location.assign(url.toString());
}

async function handleCallback() {
  const params = new URLSearchParams(location.search);
  const returnedState = params.get("state");
  if (returnedState !== sessionStorage.getItem("oauth_state")) {
    throw new Error("state mismatch");
  }
  const code = params.get("code");
  const verifier = sessionStorage.getItem("pkce_verifier");
  sessionStorage.removeItem("pkce_verifier");
  sessionStorage.removeItem("oauth_state");

  const resp = await fetch("https://as.example.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: "https://app.example.com/callback",
      client_id: "spa-demo",
      code_verifier: verifier,
    }),
  });
  const tokens = await resp.json();
  // 纯 SPA 模式下 access_token 只放内存;若需要长期刷新,优先改为 BFF 代持 refresh_token
  return tokens;
}

function base64url(buf) {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}

几个关键习惯:verifier 和 state 都存 sessionStorage(关标签即清),回调校验后立刻删除;fetch 调用 token 端点放在当前页面的 onload 处理里,不要跨组件传递;access_token 只放内存变量(React Context、Zustand store),不入 localStorage。

五、Sender-Constrained Token 与现代扩展

5.1 Bearer token 的根本问题

标准的 access_token 是 Bearer token——“持有即生效”,服务端不验证持有者身份。一旦 token 泄露(日志、浏览器、网络劫持、SSRF),攻击者可以直接使用。PKCE 保护了授权码兑换环节,但没有保护已经签发出去的 token。

现代扩展的思路是给 token 加上”持有者绑定”,让 token 离开合法客户端后不可用。主流有两条路径:DPoP 和 mTLS。

5.2 DPoP(RFC 9449)

DPoP(Demonstrating Proof of Possession)让客户端每次调用 API 时,用一把客户端持有的私钥签一个一次性 JWT,附在 DPoP header 里;access_token 里通过 cnf.jkt claim 绑定了对应公钥的指纹(thumbprint)。服务端校验 JWT 的签名、时间戳、HTU/HTM 与请求一致后放行。

DPoP proof JWT 的 payload 大致如下:

{
  "jti": "e4cbb1a7-6f86-4e54-8b8a-11c9de52e4a0",
  "htm": "GET",
  "htu": "https://api.example.com/orders",
  "iat": 1716700000,
  "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}

header:

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

AS 侧发放 token 时,客户端在 /token 请求里也附带一个 DPoP proof,AS 从中提取 jkt 写到 access_token 的 cnf claim。之后每次 API 调用都要带 proof。

DPoP 还有 nonce 机制:RS 可以挑战性地返回 DPoP-Nonce header,客户端把该 nonce 放进下一次 proof 的 nonce claim。用来防止客户端机器时钟不准导致的重放窗口问题。

和 Bearer 相比,DPoP 让攻击者即使拿到 access_token 也无法使用——他没有私钥签不出合法 proof。代价是客户端需要持久化一对非对称密钥并在每次请求签名。

5.3 mTLS sender-constrained token(RFC 8705)

在企业后端到后端的场景里,mTLS(mutual TLS)是更自然的方案。客户端用 TLS 客户端证书建立连接,AS 把证书指纹 x5t#S256 写进 access_token 的 cnf claim:

{
  "cnf": {
    "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
  }
}

RS 校验 TLS 通道上客户端证书的 SHA-256 指纹是否等于该值。不等则拒绝。

mTLS 方案在服务间调用里成本低(TLS 本就在建),适合金融、B2B API;在浏览器场景里体验差,因为用户要管理证书,所以 SPA 走 DPoP 更自然。

5.4 Pushed Authorization Requests(PAR,RFC 9126)

传统 /authorize 请求把所有参数放在 URL 里经过浏览器跳转,参数会出现在浏览器历史、Referer,且长度受限。PAR 让客户端先通过后端通道(TLS + 客户端认证)把授权参数 POST 到 /par 端点,AS 返回一个短寿命的 request_uri,然后浏览器跳转只带这个 request_uri

POST /par HTTP/1.1
Host: as.example.com
Authorization: Basic c3BhLWRlbW86...
Content-Type: application/x-www-form-urlencoded

response_type=code
&client_id=spa-demo
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid+orders.read
&state=...
&code_challenge=...
&code_challenge_method=S256

响应:

{
  "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",
  "expires_in": 60
}

浏览器跳转:

GET /authorize?client_id=spa-demo&request_uri=urn:ietf:params:oauth:request_uri:6esc_...

好处:授权请求的完整参数从未暴露给浏览器;请求可以非常大(富权限、复杂 scope);request_uri 是一次性、短寿命的,截获无用。FAPI 2.0(Financial-grade API)把 PAR 列为必选。

5.5 JWT-Secured Authorization Requests(JAR,RFC 9101)

JAR 让客户端把整个 /authorize 参数打包成签名 JWT,用 request 参数传(或 request_uri 指向它):

GET /authorize?client_id=spa-demo&request=eyJhbGciOiJSUzI1NiIs...

JWT 的 payload 里是原本的 OAuth 参数,header 里是签名算法与 kid。AS 用客户端注册时登记的公钥验签,确认参数未被中间篡改。JAR 常与 PAR 一起用——PAR 提供”不暴露给浏览器”,JAR 提供”抗篡改 + 身份证明”。

5.6 Rich Authorization Requests(RAR,RFC 9396)

scope 是粗粒度权限模型,在”转账 10 万元到账号 X”这种细粒度、结构化授权场景里无能为力。RAR 引入 authorization_details 参数,允许客户端声明结构化授权对象:

{
  "authorization_details": [
    {
      "type": "payment_initiation",
      "actions": ["initiate", "status"],
      "locations": ["https://example.com/payments"],
      "instructedAmount": { "currency": "EUR", "amount": "123.50" },
      "creditorAccount": { "iban": "DE02100100109307118603" },
      "creditorName": "Merchant A"
    }
  ]
}

AS 把 authorization_details 展示在同意页上让用户审阅,签发的 access_token 里会带上已授权的细粒度权限。Open Banking(PSD2)和 FAPI 2.0 Baseline 场景广泛使用。

5.7 FAPI 2.0:把这些扩展组合成 profile

上面五个扩展(DPoP、mTLS、PAR、JAR、RAR)各自解决一类问题,单独使用都是可选项。FAPI 2.0(Financial-grade API)Baseline 把它们按金融级要求打包成了一个强制 profile:

可以把 FAPI 2.0 当作”OAuth 2.1 的金融加强版”。对非金融场景它可能过度,但里面每条要求都有明确威胁模型对应,按需裁剪非常合适作为企业级 IAM 的默认 profile。

六、常见攻击与防御

PKCE 与 OAuth 2.1 关闭了一批攻击面,但协议层面能做的有限,剩下的坑大多落在实现上。

6.1 开放重定向(Open Redirect)

redirect_uri 注入或利用子路径上的开放重定向漏洞,使授权码流转到攻击者控制的页面。防御:

6.2 CSRF:state 参数必须存在且校验

客户端必须为每次授权生成强随机 state,并在回调中校验其与本次会话绑定。缺少校验会允许攻击者把自己的授权回调”注入”到受害者的会话,使受害者在不知情的情况下绑定了攻击者的账户。OAuth 2.1 要求 state 或 PKCE 至少存在其一,但两者最好同时存在——PKCE 防拦截,state 防 CSRF,用途不同。

6.3 授权码重放

code 必须:

如果 AS 没做单次消费,攻击者截获一次兑换的 HTTPS 请求元数据(即便解不出内容)也可能在短时间内重放。成熟实现通常采用”兑换即删除 + 一次失败即撤销整个 code”的双保险。

6.4 Token 泄露路径全景

梳理一遍 access_token / refresh_token 的泄露路径:

6.5 Mix-Up Attack

多 IdP 场景下,客户端同时支持多个 AS(例如”用 Google 登录”和”用 Microsoft 登录”)。攻击者诱导受害者点击”Google 登录”,但把授权请求偷偷送到攻击者控制的 AS;AS 把 code 回调到合法客户端的 redirect_uri;客户端不知道这个 code 属于哪个 IdP,默认送到 Google 的 /token 端点——结果要么泄露攻击者的 code 给 Google(低危),要么客户端拿攻击者账户的 token 当受害者处理(高危,导致账户合并漏洞)。

RFC 9207(OAuth 2.0 Authorization Server Issuer Identification)要求 AS 在回调中返回 iss 参数,客户端校验 iss 与期望 AS 一致后再去兑换。另外 PAR + JAR 的组合也能缓解,因为授权请求与 AS 绑定。

6.6 Downgrade:client_secret 与 PKCE 同时不在

一些旧 AS 在同时支持 confidential 与 public client 时,如果客户端不提交 client_secret 也不提交 code_verifier,会错误地”跳过”验证。修复是在 AS 上强制——只要客户端是 public 类型必须有 verifier,只要是 confidential 类型必须有 secret;对开启了 PKCE 的 confidential client 两者同时校验。

6.7 refresh_token 永久有效

public client 的 refresh_token 如果不过期不 rotate,一次泄露等于终生失守。运营层面需要:

6.8 CSP 与 XSS:前端侧的兜底

所有浏览器侧的 OAuth 方案最终都依赖浏览器本身的同源与脚本隔离。XSS 一旦发生,无论 token 放在哪里(localStorage、内存、甚至 HttpOnly Cookie 借助 fetch 调用)攻击者都能以用户身份调用 API。协议层无法修复 XSS,只能在前端工程侧做以下组合:

这些不是 OAuth 规范要求,但缺了它们 PKCE/DPoP 的保护会被轻易绕过。

6.9 同意页欺骗与 phishing

攻击者在合法 AS 下注册一个看起来像官方的 client,诱导用户授权大权限;或者把 client name 改成 Unicode 混淆字符骗过同意页。防御在 AS 侧:

七、工程坑点

真实工程落地中,协议合规只是起点,下面这些是反复踩过的坑。

  1. SPA 用 localStorage 存 access_token:任何页面的 XSS 都能偷走全部 token。宁可用内存 + BFF,或者 HttpOnly + SameSite=Lax 的 session cookie。
  2. 把 verifier 写到 localStorage:比 token 更危险——verifier 泄露等于 PKCE 失效。只能放 sessionStorage 或内存,兑换后立刻 delete。
  3. 忘记校验 state:大量开源 OAuth 中间件默认开 state 但允许关,线上关掉以后出过账号合并漏洞。
  4. redirect_uri 注册表带通配:一些 AS 界面允许 https://*.example.com,这是历史包袱;生产上逐条列出精确 URL,哪怕 50 条。
  5. AS 不强制 S256:让客户端能选 plain,攻击者 MITM 改掉方法绕 PKCE;AS 配置侧白名单 method=S256。
  6. code 单次消费未原子化:高并发下用 SELECT + DELETE 两步会出现双兑换;用 DELETE ... RETURNING 或 Redis GETDEL 保证原子。
  7. 错误回显带 URL:5xx 页面原样显示 LocationReferer,让 code / token 出现在错误页;统一做脱敏。
  8. DPoP proof 缺少 ath:没绑定 access_token 时 proof 可被挪用到不同 token;RFC 9449 已把 ath 改为要求,实现必须带上。
  9. DPoP 的 nonce 缓存不够分布式:多实例 RS 之间不共享 jti 集合,攻击者可以跨实例重放;用 Redis / Memcached 做共享存储,TTL = proof 有效期 + 容忍抖动。
  10. refresh_token rotation 与多标签冲突:SPA 同时打开两个标签并发刷新,一个拿到新 token、一个旧 refresh_token 被标为泄露触发 chain revoke。解法:BFF 里加互斥锁;或者前端单例刷新。
  11. OIDC discovery 未 pin:信任 /.well-known/openid-configuration 的所有 endpoint 字段,攻击者劫持后把 token_endpoint 指到他自己的服务器。需要 HSTS + 证书 pinning + 敏感 endpoint 硬编码。
  12. mobile 用 WebView 做授权:WebView 可被宿主 App 注入脚本读密码,RFC 8252 禁止;改用系统浏览器或 ASWebAuthenticationSession / Chrome Custom Tab。

八、选型建议

新项目直接选择 OAuth 2.1 + 授权码 + PKCE,没有第二个值得考虑的主路径。下面按场景给出具体推荐。

sender-constrained 选型上,B2C 场景(SPA + 公网 API)选 DPoP,B2B 场景(内部后端互调)选 mTLS,两者不冲突可共存。

九、参考资料


上一篇企业单点登录:OIDC 与现代 SSO

下一篇SAML 还值得学吗:企业遗留 SSO 的现实世界

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

身份与访问控制工程

从 OIDC、OAuth 2.1、SAML、SCIM 到多租户权限、CIAM、PAM 与身份平台选型——系统拆解现代身份与访问控制的协议、架构与工程实践。

2026-04-21 · architecture / security

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。


By .