KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 生产边界:从单实例到真实部署 — keel 龙骨

前六章的所有结论都有一个共同的前提:一个进程、一份内存、一把密钥、一段 http。 这一章逐条把这个前提拆掉。每一节都长成同一个形状: 教学替身是什么 → 真实替换点是什么 → 不换会出什么事。

前六章的所有结论都有一个共同的前提:一个进程、一份内存、一把密钥、一段 http。
这一章逐条把这个前提拆掉。每一节都长成同一个形状:
教学替身是什么 → 真实替换点是什么 → 不换会出什么事。


一、现场:教学能跑,上线第一周就出事

四个典型现象,都来自「教学环境与生产环境只差一个部署形态」:

  1. 滚动重启之后,所有人被踢下线。 会话在进程内存里,重启即清空。
  2. 扩到两个副本之后,登录开始「有时成功有时失败」。 第一步落在副本 A、
    第二步落到副本 B,B 说「这个 code / 这个 state 我不认识」。
  3. 换了一次签名密钥,所有在途令牌全部失效。 应用的 JWKS 缓存里只有旧密钥,
    或者代码只用「唯一那把密钥」验签。
  4. 加了反向代理之后,所有应用开始报签发者不对。 IdP 生成令牌时用的是内部地址,
    应用配置里写的是对外域名。

这四个现象有一个共同点:它们在单实例 + 本地 http 下不可能出现。
所以这一章的问题不是「怎么加固」,而是「哪些默认值在换部署形态时必须一起换」。

二、会话存储:为什么多副本必须共享

链路上有三类「一次性的状态」,最容易被忽略的是它们必须在多个副本之间共享:

状态 用途 生命周期 现存哪里(教学) 真实替换
state / nonce / code_verifier 本次登录的事务上下文 一次登录 应用进程内存 共享缓存或签名过的 cookie,带 TTL
授权码 code 换 token 的凭据 一次兑换 IdP 进程内存 共享存储 + 单次消费的原子操作
IdP 会话 「已登录」的载体 会话期 IdP 进程内存 共享存储 / 会话服务,可批量失效
flowchart TD
    subgraph U["浏览器"]
        B1["cookie:已登录载体"]
        B2["回跳:state + code"]
    end
    subgraph LB["入口(HTTPS 终结 + 负载均衡)"]
        L1["代理层"]
    end
    subgraph IDP["IdP(多副本)"]
        P1["副本 A"]
        P2["副本 B"]
    end
    subgraph APP["应用(多副本)"]
        A1["实例 A"]
        A2["实例 B"]
    end
    S1[("共享会话存储")]
    S2[("授权码 / state 存储<br/>TTL + 单次消费")]

    B1 --> L1
    L1 -->|"① 落到副本 A:发起授权"| P1
    P1 -->|"写 code"| S2
    P1 -->|"写会话"| S1
    B2 --> L1
    L1 -->|"② 落到副本 B:换 token"| P2
    P2 -->|"读 code(必须读得到)"| S2
    P2 -->|"读会话"| S1
    P2 -->|"③ 用对外域名生成 iss"| A1
    A1 -->|"④ 校验 iss 与 aud"| A2
    P1 -.->|"副本 A 内存里的 code:副本 B 看不见"| X1["Code not valid / state 不符<br/>偶发失败"]
    L1 -.->|"代理没传对外协议/主机名:iss 变成内部地址"| X2["issuer 不匹配<br/>全体验签失败"]

三类状态里,state 那一类最常被漏掉:它看起来只是「一次性的小数据」,
随手放进了进程内存或 sessionStorage。等到多副本上线,「偶尔失败」就开始了——
而且它偶发、难复现,正好是最贵的故障类型(06 章故障④)。

三、令牌寿命与刷新策略

主案例里两个寿命数字是:

expires_in         = 300
refresh_expires_in = 1800

它们的含义不同,不要把它们看成「一个短一个长」这么简单:

数字 它约束的是 它不约束的是
expires_in = 300 access token 的有效期(5 分钟) 已经登录的会话;会话另有寿命
refresh_expires_in = 1800 refresh token 的有效期(30 分钟) 用户「必须重新输密码」的时间——刷新成功的话可以继续

设计时要写下来的两句话是:「access token 泄露后的窗口有多长」 与
「用户最长多久必须重新认证一次」。两个问题对应两个不同的数字。

反直觉的那一条:轮换不等于作废

第一次 refresh -> 200
新的 refresh_token 与旧的是同一个吗? False
用【旧】refresh_token 再换一次 -> 200
用【新】refresh_token 再换一次 -> 200 (应该还能用)

读法要准确:第一次刷新确实轮换了 refresh token(新的与旧的不同),
但旧的那个仍然能换成功。
也就是说,这个实现在默认配置下
只做轮换、不做 reuse detection——它不会因为「旧令牌又被用了」而判定泄露。

这条为什么重要:

反过来的错误也要避免:不要以为「轮换」等于「旧令牌立刻作废」。
本课实验台的默认行为就与你可能预想的不同,而且它是照抄常见实现的默认值,
不是 bug。要验证自己的系统属于哪一种,只有一条路:拿旧的 refresh token 再换一次,看返回什么。

四、密钥轮换与 kid

为什么必须能同时存在两把密钥

kid 的作用(03 章)在轮换这件事上才完全显现:

不换密钥:任何时候只有一把,应用缓存一份就行——但泄露后无法收场
换密钥:  公布新旧两把,应用按 kid 选 —— 但要求应用支持「多把并存」

轮换的正确姿势是三步而不是一步:

① 公布新密钥(旧密钥仍在)→ 新签发的令牌用新 kid
② 等一个「最长令牌寿命」的窗口        ← 等所有在途令牌过期
③ 撤掉旧密钥

跳过第 ② 步就会全体掉线:旧令牌还在用旧 kid,而应用那边已经取不到它了。

同一个时刻本来就有多把密钥

一个容易被忽略的事实:「签名密钥」不止一把。同一个 realm 的 JWKS 里两把是
use = sig / use = enc 的分工(03 章),而在实际报文里还能看到更多 kid:

ID Token 的 header:      kid = 6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4   (RS256)
refresh token 的 header: kid = c0e1c7b4-b238-40f6-9ca0-a294bd0b1b3a           (HS512)

排障含义:看到「kid 不认识」时,先确认你查的是哪一套密钥。
按 kid 去一张表里找、找不到就报错,是常见的实现缺陷;
正确做法是按 kid 去该类型令牌对应的密钥集合里找,未命中时限流刷新一次。

应用侧怎么取密钥

做法 后果
每次校验都去取 JWKS 令牌校验被 IdP 的 JWKS 端点拖住;该端点成为热点与单点
启动时取一次,永不刷新 轮换之后验不过,且要重启应用才能恢复
缓存 + 按 kid 未命中时刷新(且限流) 常态无额外请求,轮换与紧急换密钥都能跟上

第三种是能用的那一档。注意「限流」那一半:一个伪造的、带随机 kid 的令牌
会打穿「未命中就刷新」的逻辑,把它变成对 IdP 的放大器。

五、HTTPS 与代理下的 issuer 一致性

iss 是字面量(03、06 章)。上了生产,这个字面量会经过至少三个地方,
它们必须一致:

IdP 配置里的 issuer
    == 令牌里的 iss
    == 应用配置里的期望值
    == 用户实际上访问的地址

最常见的破坏者是代理终止 TLS:用户访问 https://sso.example.com,
代理转发到内部的 http://idp:8080。如果 IdP 按「自己看到的请求」生成 iss,
令牌里就会是内部地址,与应用配置不符——现象与「签名不对」几乎一样。

三件必须一起做的事:

  1. 让 IdP 知道对外地址(配置固定的 issuer,或正确传递并信任 X-Forwarded-Proto / X-Forwarded-Host);
  2. 确认重定向也不被改写:授权回跳、post_logout_redirect_uri 都要用对外地址;
  3. cookie 属性随之调整:HTTPS 之后 Secure 才成立(06 章故障③)。

顺带把 06 章那个最容易被当成「小问题」的替换点收进来:
http → https 不是一个开关,它同时改变了 cookie 的可回带性、
iss 的字面量和「哪些地址算可信来源」
。联调环境也走 HTTPS,
是让这类差异在生产前暴露的唯一办法。

六、教学替身 → 真实替换点(总表)

# 教学替身(本课 / 配套项目) 真实替换点 不换会出什么事
1 MiniOP 单进程实现 多副本 IdP(或托管身份服务) 单点故障:IdP 一停,所有应用登不进
2 HS256 对称签名 RS256 / ES256 + 公开 JWKS 校验密钥 = 签发密钥,任何持有它的组件都能伪造身份
3 内存密钥环 + 固定 kid 密钥集轮换,新旧并存,按 kid 选 换密钥即全员掉线;或旧密钥永久有效,等于没轮换
4 内存 codes / sessions / state 共享存储 + TTL + 单次消费的原子操作 多副本下偶发失败;重启即掉线
5 固定时钟 Clock 服务器时间同步 + 偏移监控 exp / iat 判断成片误判(06 章故障①)
6 http://localhost + Secure; SameSite=None HTTPS + 按场景设对的 cookie 属性 脚本与浏览器行为分叉,联调通过、上线才炸
7 应用侧 Browser 类 真实浏览器 + 明确的令牌存放位置(内存 / HttpOnly cookie) 令牌暴露面失控(03 章第五节)
8 内存 refresh_tokens 映射 轮换 + (按风险)开启 reuse detection 令牌被盗不会被发现;旧令牌一直可用
9 单 realm、单 issuer 多 realm / 多 IdP,代理后 issuer 一致 iss 不一致的全量验签失败(05 章、本节)
10 没有审计与账号生命周期 登录/登出/授权变更审计 + 离职回收通道 出事查不到人;离职权残留
11 不做限流与容量评估 授权/令牌端点限流,JWKS 缓存,容量按峰值登录量估算 登录风暴(例如早高峰集中登录)把 IdP 打穿

优先级建议(如果只能先换三件):第 4、3、6 项——
它们分别对应「上线就偶发失败」「换密钥就全掉线」「联调过了上线炸」三类最贵的故障。

动手:可观察结果

cd courses/identity/sso-engineering/course/project
python examples/06_token_lifetime_and_rotation.py

要观察并解释的四行:

  1. expires_in = 300 与 refresh_expires_in = 1800——两种寿命,约束不同的事;
  2. 第一次刷新后 新的 refresh_token 与旧的一样吗 = False——确实轮换了;
  3. 用旧 refresh token 再换一次 → 成功——确实没有 reuse detection;
  4. 推进 1801 秒后刷新 → Refresh token expired——refresh_expires_in 说的就是这个点。

再补一次密钥轮换(测试 test_rotation_keeps_old_tokens_verifiable):
装入新密钥之后,旧 token 仍然验得过,新签发的 token 用新 kid,JWKS 里有两把。
这就是第四节那三步里的第 ①②步可观察的样子。

故障注入

注入 改法 预期现象 属于哪一类
清掉 IdP 会话存储(模拟重启) 让会话只在内存里,然后清空 所有人被踢下线 会话存储不共享
让两个副本各存一半 code 用两个 MiniOP 实例分别签发与兑换 兑换失败:Code not valid 授权码存储不共享
只保留一把密钥 轮换时删掉旧 kid,再验旧 token unknown kid 密钥轮换窗口
撤权后继续用旧 access token 改角色后立刻用旧令牌调接口 仍然成功(直到 exp) 自包含令牌的撤销延迟
用旧 refresh token 换新令牌 刷新后再用旧的换一次 成功——默认检测不到 reuse detection 未开启
把 issuer 改成内部地址 配置里写 http://idp:8080 全体 iss 不匹配 代理下 issuer 不一致

前两行在配套项目里可以直接看到(用两个 MiniOP 实例各持一半状态即可);
后面几行属于部署形态级注入——它们的现象只在多副本、多密钥、有代理的环境里出现,
所以要拿自己的部署拓扑去对。

生产边界

这一章本身就是「生产边界」。落在你手上的动作清单是:

自测题

  1. 为什么 state 这种「一次性小数据」也必须放进共享存储?它的失败表现是什么形状?
  2. expires_in = 300 与 refresh_expires_in = 1800 分别约束什么?
    「用户最长多久必须重新认证」由哪一个决定?
  3. 「刷新令牌轮换了」与「旧刷新令牌作废了」是同一句话吗?给出一个验证方法。
  4. 密钥轮换的三步是什么?跳过中间那一步会发生什么?
  5. 加了反向代理之后,iss 不一致的根因是什么?为什么它的现象与「签名不对」很像?
  6. 从本章总表里挑三项,说明为什么它们优先级最高(提示:分别对应哪三类故障)。

现在能解释什么

进入 keel 阅读