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
这四件事都要看清楚:
- 协议流里有一次人机交互。凡是出现 200 + 表单,就说明这次是要被登录的人在场,
而不是「应用和服务器的对话」。 - 表单的
action上带了session_code/execution/tab_id,它们把这次授权请求
暂存起来(对应我们实验台里的OP_AUTH_REQcookie)。凭据提交时要把它们原样带回去——
这是一次「中间状态」,此时还没有任何身份结论产生。 - 这一步已经在下发 cookie 了。响应头里出现
AUTH_SESSION_ID、KC_AUTH_SESSION_HASH、KC_RESTART——注意这里还没有KEYCLOAK_IDENTITY:它是登录成功之后才出现的那一个,
04 章会用它来判定「已登录」。 - 表单里的
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
四个参数各有职责:
state原样回来(应等于应用发出去的那个)——不相等就必须中止,不能再走下一步;session_state是会随会话状态变化的值,前端可以用它感知「会话在别处被登出了」;iss是 RFC 9207 要求的授权响应发行者标识,多 IdP 场景下靠它确认「这条答复来自哪一个 IdP」;code是这次的凭据。
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 = 原文 ← 只有真发起方能出示
两条要点:
- 只有摘要过浏览器。截获
code的攻击者拿不到verifier,换不出 token。 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必须不可预测,且必须一次性。用固定字符串、时间戳或自增 id,
等于把 CSRF 防护关掉。回跳里state不符时唯一正确的动作是中止:
不要去换 token,也不要「顺便看看能不能换成功」——那正好会烧掉 code。iss是给多 IdP 场景准备的。只接一家 IdP 时它看起来多余;
一旦同一套代码要接多家(05 章),没有它就无法分辨「这条回跳本来该由谁发出」,
攻击者可以用 A 家的回跳喂给配置了 B 家的应用。
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 与机密客户端同时失效 |
自测题
- 为什么第一步 GET 拿回来的是登录页而不是
code?这一步下发的 cookie 里,
哪一个的出现标志「已经登录了」? state与nonce各自在什么位置往返?如果两者互换使用,会分别漏掉什么防护?- 一个应用把「换 token」写在
state校验之前。用户点了一次登录,回跳的state被篡改,
接下来会发生什么?用户再刷新一次页面会看到什么错误? - 为什么「不存在的 code」与「已经用过的 code」返回同一句话?这对排障有什么影响?
- 公开客户端为什么必须用 PKCE?如果它是机密客户端,PKCE 是不是就不需要了——
说说你的理由(提示:授权码注入这类攻击不要求攻击者能访问令牌端点)。
现在能解释什么
- 一次授权码流的四步:GET 授权端点(200 登录页)→ POST 凭据(302 带四个参数)→
换 token(200 三种 token)→ 校验 ID Token; state/nonce/code_verifier三者各自防什么、放在哪一侧校验;- 为什么
code是复合的却仍不可读,以及为什么应用不该从它里面读任何东西; - 三条失败路径的原文报错,以及「PKCE 失败那次也消耗了 code」这个反直觉事实;
- 一次登录里哪些校验由 IdP 做、哪些必须由应用自己做——06 章的排障表从这里长出来。