KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 授权码流与 PKCE:把一次登录逐步拆开 — keel 龙骨

这一章兑现第 01 章的那一眼:那两段 URL 里的每个参数,是谁在什么时候放进来的。 顺序不是任选的——每一步都在为下一步制造一个只能用一次的东西。 把这些「一次性」搞错一处,就会得到本章最后那三条失败路径中的一条。

这一章兑现第 01 章的那一眼:那两段 URL 里的每个参数,是谁在什么时候放进来的。
顺序不是任选的——每一步都在为下一步制造一个只能用一次的东西。
把这些「一次性」搞错一处,就会得到本章最后那三条失败路径中的一条。


一、现场:回跳回来一片空白

最常见的接入故障长这样:用户点了「用 SSO 登录」,跳走、登录、跳回来,
地址栏里确实多了 ?code=...,然后页面一片空白,应用日志里只有一句 invalid_grant。

这时候的排查通常从「配置对不对」开始,于是把 client_id、redirect_uri、client_secret
逐个比对一遍,全都对。问题不在配置,在顺序:先换 token 再校验 state、
把 code_verifier 存到了第二个请求拿不到的地方、或者失败之后重试了一次。
这一章要把这条流拆到能指出「你错在哪一步」。

二、四步走通:每一步的原始报文

flowchart TD
    subgraph UA["浏览器"]
        U1["地址栏 + cookie jar"]
    end
    subgraph APP["应用(SP / RP)"]
        A1["① 生成 state / nonce / code_verifier"]
        A2["④ 收 code:先校验 state,再校验 iss"]
        A3["⑤ 换 token:带 code + code_verifier"]
        A4["⑥ 校验 ID Token 六项"]
    end
    subgraph IDP["IdP(OP)"]
        I1["② 授权端点"]
        I2["登录页"]
        I3["③ 令牌端点"]
        I4[("会话<br/>sessions")]
        I5[("授权码<br/>codes")]
    end

    A1 -->|"GET 授权端点(带 state/nonce/challenge)"| I1
    I1 -->|"200 + 登录页(无会话)"| U1
    U1 -->|"POST 凭据(带 session_code)"| I2
    I2 --> I4
    I2 -->|"302 + code/state/iss/session_state"| A2
    A2 --> A3
    A3 --> I3
    I3 --> I5
    I3 -->|"200 + access_token / id_token / refresh_token"| A4
    A4 --> AB["登录完成:sub + sid"]

    F1["400 页面<br/>Cookie not found"] -.-> I2
    F2["应用侧中止<br/>invalid_state / invalid_issuer"] -.-> A2
    F3["invalid_grant<br/>PKCE verification failed: Code mismatch"] -.-> A3
    F4["invalid_grant<br/>Code not valid(code 已用过)"] -.-> A3
    F5["invalid_id_token<br/>签名 / iss / aud / nonce / at_hash 任一不符"] -.-> A4

① 应用生成三个一次性值,然后把浏览器送去授权端点

应用在发起这次登录之前先生成三样东西:

code_verifier  = SsVH2Ja25l4B-VkTksOhmYU7qrBJU1pQ-pRfywZv470
code_challenge = 8Vklj4QX3rXa9CurOob2v3rc6v8ei2wZzj6s7Uij7tY (S256 of verifier)
state          = AsoTwZFFJyDK-urhFMy_jw
nonce          = _MJjvwPYDooEWutUkyihuA

它们的分工不一样,这是本章第一个要记住的对照:

值 送去 IdP 的是 谁最后校验它 防的是什么
state 原文 应用(比对回跳) 别人伪造一次回跳塞给你一个 code
nonce 原文(写进 ID Token) 应用(比对 ID Token) 一个旧的 ID Token 被重放成新的登录
code_verifier 只送摘要(challenge) IdP(换 token 时比对) code 被截获后被别人兑换

② 第一步 GET 的结果:200,不是 302

这是最容易让人误判的一步。带着授权的请求去访问授权端点,拿回来的不是 code,而是一张登录页:

status = 200 | final url = /realms/sso-lab/protocol/openid-connect/auth
登录表单 action = http://localhost:8081/realms/sso-lab/login-actions/authenticate?session_code=UhPBor8qtpzc2GTl2kmVlekMmifS2JRsAHi6BteTPs4&execution=71b98d7f-ef2c-43cd-a02a-eceb557db528&client_id=sp-spa&tab_id=SLXBqrVteEU&client_data=...
登录页的 input 字段 = credentialId, password, username

这四件事都要看清楚:

  1. 协议流里有一次人机交互。凡是出现 200 + 表单,就说明这次是要被登录的人在场,
    而不是「应用和服务器的对话」。
  2. 表单的 action 上带了 session_code / execution / tab_id,它们把这次授权请求
    暂存起来(对应我们实验台里的 OP_AUTH_REQ cookie)。凭据提交时要把它们原样带回去——
    这是一次「中间状态」,此时还没有任何身份结论产生。
  3. 这一步已经在下发 cookie 了。响应头里出现 AUTH_SESSION_ID、KC_AUTH_SESSION_HASH、
    KC_RESTART——注意这里还没有 KEYCLOAK_IDENTITY:它是登录成功之后才出现的那一个,
    04 章会用它来判定「已登录」。
  4. 表单里的 username / password 是给人填的,其余字段原样回带——
    自己写客户端时把这一步写成「只提交用户名密码」是常见错误。

③ POST 凭据:302,回跳参数正好四个

凭据正确时,IdP 建会话、签发一个授权码,然后把浏览器送回应用:

status = 302
Location = http://localhost:8090/callback?state=AsoTwZFFJyDK-urhFMy_jw&session_state=VnXksyjhgJexcsLOoOE5fc8c&iss=http%3A%2F%2Flocalhost%3A8081%2Frealms%2Fsso-lab&code=44f80113-b402-a38e-bbc0-56310447ebf8.VnXksyjhgJexcsLOoOE5fc8c.74717848-0bf1-4f44-81f2-e31422eb12c8
回跳带回来的参数: code, iss, session_state, state

四个参数各有职责:

code 的形状本身也值得看一眼:它是三段用点连接的——44f80113-...、VnXksyjhgJexcsLOoOE5fc8c
(与 session_state 同值)、74717848-...(这是 sp-spa 这个客户端的 uuid)。
它是复合的,但仍然是不可读的:真实实现把「哪条会话、哪个客户端」编进去,是为了让令牌端点
不必再查一次索引。教学用的最小实现发的是一段不透明 id(code-0009),
两者在协议层面等价:应用都不该从 code 里读任何东西。

④ 换 token:带 code 和 verifier 去令牌端点

status = 200
响应字段: access_token, expires_in, id_token, not-before-policy, refresh_expires_in, refresh_token, scope, session_state, token_type

这一步是服务端到服务端的(应用直接访问 IdP 的令牌端点,不经过浏览器),
所以要带 code_verifier——只有真正发起这次登录的那个应用才持有它。
换回来的三样东西在 03 章展开,这里只需记住:expires_in = 300(access token 5 分钟)、
refresh_expires_in = 1800(refresh token 30 分钟)——这两个数字是 07 章讨论寿命与刷新的起点。

⑤ ID Token 拆开看

header = {"alg": "RS256", "typ": "JWT", "kid": "6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4"}
claims 的键: acr, at_hash, aud, auth_time, azp, email, email_verified, exp, family_name,
             given_name, iat, iss, jti, name, nonce, preferred_username, sid, sub, typ

nonce 出现在这里,是第 ①步埋下的那颗钉子:应用拿它和 claims["nonce"] 比对,
不相等就说明这个 ID Token 不是本次登录产生的。03 章逐项讲其余 claim。

三、PKCE 到底防住了什么

授权码流的原始设计里有一个漏洞:code 是经过浏览器传回来的。
如果应用是公开客户端(SPA、移动端),它拿不住 client_secret,
那么任何截获到这个 code 的人都能拿它去换 token。

PKCE(RFC 7636)的做法是:应用生成一个随机 code_verifier,
先只把它的摘要 code_challenge 交给 IdP(在授权请求里),
换 token 时再出示原文。IdP 比对摘要,不符就拒绝。

授权请求: code_challenge = S256(code_verifier)     ← 只送摘要
换 token: code_verifier  = 原文                    ← 只有真发起方能出示

两条要点:

  1. 只有摘要过浏览器。截获 code 的攻击者拿不到 verifier,换不出 token。
  2. plain 也是一种方法,但那时送出去的就是原文,等于没有保护。discovery 文档里
    code_challenge_methods_supported = ['plain', 'S256'] 说的是支持的能力,
    不代表两种都该用——选 S256。

四、三条失败路径(每一条都要亲手跑)

失败一:code_verifier 被篡改

用被篡改的 verifier -> 400 {"error": "invalid_grant", "error_description": "PKCE verification failed: Code mismatch"}

失败二:同一个 code 再用正确的 verifier 换

同一 code 再用正确 verifier -> 400
{"error": "invalid_grant", "error_description": "Code not valid"}

这两条连起来是本课最反直觉的一个事实:第一次失败也把 code 用掉了。
换 token 的顺序是「先把 code 标记为已消耗,再校验 PKCE」——
所以 verifier 填错的那一次不是「无害的重试失败」,而是把这个 code 烧掉了。

实际后果很具体:如果你的应用在校验顺序上写错了(例如先换 token 再校验 state),
那么一次 state 不符的请求会消耗掉用户的 code;用户刷新一下页面,
拿到的还是同一个 code,第二次必然失败,而报错是 Code not valid——
看起来像「code 被用过了」,其实是「你的第一次尝试用掉了它」。

失败三:同一个 code 换两次

--- 反证:同一个 code 再换一次 ---
status = 400
{"error": "invalid_grant", "error_description": "Code not valid"}

注意这里和「一个从来没发过的 code」返回的是同一句话。
这是设计上的取舍:不区分「不存在」与「已用过」,攻击者就无法用错误信息探测
「这个 code 是否曾经真实存在过」。排障时反过来说也成立——
看到 Code not valid 时,你要先排除的不是「code 是假的」,而是「它是不是已经用过了」。

五、state 与 iss:两个容易被当成「装饰」的参数

state 与 nonce 也经常被混为一谈,两者的位置不同、防的东西也不同:

state  在授权请求与回跳 URL 里往返 —— 防「伪造一次回跳」
nonce  从授权请求进、从 ID Token 出 —— 防「重放一个旧的 ID Token」

动手:可观察结果

cd courses/identity/sso-engineering/course/project
python examples/01_authorization_code_flow.py   # 四步走通
python examples/02_tampered_verifier.py         # 失败一 + 失败二
python examples/03_reused_code.py               # 失败三

观察顺序按本章的节号走:status = 200 的登录页 → 302 的四个回跳参数 →
换 token 的 expires_in / refresh_expires_in → ID Token 的 aud。
三个失败示例各自断在哪一步,就是本章那三节讲的位置。

故障注入

在本章的四步里挑一步破坏,看它断在哪里:

破坏 命令或改法 预期现象
篡改 verifier python examples/02_tampered_verifier.py PKCE verification failed: Code mismatch,且随后正确 verifier 也失败
复用 code python examples/03_reused_code.py Code not valid
换 state 把回跳 query 里的 state 改成任意值再交给应用 应用侧拒绝:invalid_state(且此时还没发出换 token 请求)
删掉 iss 把回跳 query 里的 iss 去掉 应用侧拒绝:invalid_issuer
换一个没登记的 redirect_uri 把端口 8090 改成 9999 400 Invalid parameter: redirect_uri(授权端点直接拒掉)

最后两条要对比着看:state / iss 是应用侧拒的,redirect_uri 是 IdP 侧拒的。
同一次登录里,不同方负责不同的校验——这正是 06 章那张排障表的骨架。

生产边界

教学里这样 真实部署要换成 不换会出什么事
用 plain 也能跑通 强制 S256,并在登记时只允许 S256 plain 下 verifier 原文过浏览器,PKCE 形同不存在
state 用顺序 id 密码学随机、一次性、与本次事务绑定存储 顺序 id 可预测:攻击者能构造一次「看起来合法」的回跳
state / nonce 存在内存里 存进会话或签名过的临时存储(TTL 与用户绑定) 多副本部署时第一步在 A 副本、回跳落到 B 副本,state 对不上
code 存在内存字典、TTL 靠进程 共享存储 + 显式 TTL + 单次消费的原子操作 两个并发请求可能同时消耗同一个 code;重启即丢
授权码有效期不设上限 短寿命(实现里常见几十秒到几分钟) 截获的 code 有更长的可利用窗口
应用直接访问令牌端点 保持服务端到服务端,绝不放进前端 把 client_secret 或 code_verifier 发到浏览器,PKCE 与机密客户端同时失效

自测题

  1. 为什么第一步 GET 拿回来的是登录页而不是 code?这一步下发的 cookie 里,
    哪一个的出现标志「已经登录了」?
  2. state 与 nonce 各自在什么位置往返?如果两者互换使用,会分别漏掉什么防护?
  3. 一个应用把「换 token」写在 state 校验之前。用户点了一次登录,回跳的 state 被篡改,
    接下来会发生什么?用户再刷新一次页面会看到什么错误?
  4. 为什么「不存在的 code」与「已经用过的 code」返回同一句话?这对排障有什么影响?
  5. 公开客户端为什么必须用 PKCE?如果它是机密客户端,PKCE 是不是就不需要了——
    说说你的理由(提示:授权码注入这类攻击不要求攻击者能访问令牌端点)。

现在能解释什么

进入 keel 阅读