口径先钉住 :本篇所有判据在跑实验之前 写定并哈希锁定(
experiments/jwt-sub-scope/PREREG.sha256)。 协议原文两处,都可匿名复核: - RFC 7519 §4.1.2 (https://www.rfc-editor.org/rfc/rfc7519.txt,第 477--478 行) - OpenID Connect Core 1.0 (https://openid.net/specs/openid-connect-core-1_0.html) 复现脚本、每一版翻车记录都在experiments/jwt-sub-scope/。 本文没有引用学术文献,见文末引用节。
事情是这样的
我有一套东西,按用户聚合数据。逻辑很简单:令牌来了,解出来,拿到用户标识,归到对应的桶里。
那个桶的 key,我写的是令牌里的 sub。
跑了一段时间,没出过事。直到我为了做对照实验,顺手签了两个不同的签发者------
bash
# 签发者 A
{"iss": "iss-a", "sub": "user-42"}
# 签发者 B,别的什么都没变
{"iss": "iss-b", "sub": "user-42"}
两枚令牌都是我自己签的,签名都对,验签都过,sub 都有值 ,也完全合规。
然后我那个聚合判据告诉我:这是一个人。
先说协议怎么说的
我不是看了代码才怀疑的,我去查了规范原文。RFC 7519 §4.1.2,sub 那一条,逐字是这样:
The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique.
翻译过来就是:sub 要么在「签发者内部」唯一,要么全局唯一。
注意这句话给了两个选项。第二个选项(全局唯一)要求高,多数人做不到------因为 sub 通常就是邮箱、手机号、用户名这类东西,你控制不了别的签发者给你发什么。
所以实际上大家都在用第一个选项:在签发者内部唯一。
而这正是坑的入口。
OpenID Connect Core 1.0 把这件事说得更直接:
Therefore, the only guaranteed unique identifier for a given End-User is the combination of the iss Claim and the sub Claim.
唯一保证的标识符,是 iss 和 sub 的组合。
这两句话合起来,意思很明确:sub 单独拿出来,它的作用域是一个签发者。你要拿它当全局主键,你得自己保证它全局唯一------而协议并没有保证这一点。
我自己测了一遍
文档说的是文档说的。文档能证明「标准要求如此」,证明不了任何一个具体系统真的会出错。
所以我做了个实验,在 experiments/jwt-sub-scope/,四枚令牌:
臂 / 令牌 / iss / sub
- A-1 ------ 令牌一 ------
iss-a------user-42 - A-2 ------ 令牌二 ------
iss-a------user-99 - A-3 ------ 令牌三 ------
iss-b------user-42 - A-4 ------ 令牌四 ------
iss-b------user-77
A-1 和 A-3 是关键:sub 相同,iss 不同。
跑出来的结果:
css
错误判据(只按 sub 聚合):
user-42 ← A-1, A-3 ← 判成了同一个人
user-77 ← A-4
user-99 ← A-2
→ 4 枚令牌被压成 3 个「用户」
正确判据(按 iss + sub 聚合):
iss-a / user-42 ← A-1
iss-a / user-99 ← A-2
iss-b / user-42 ← A-3
iss-b / user-77 ← A-4
→ 4 枚令牌,4 个用户
四枚令牌全部验签通过。没有一枚是非法的。
最要紧的是它一声不吭
这是我把它写出来的原因。
那个错的判据,从头到尾没有抛异常、没有返回错误码、没有打任何日志。它就是安安静静地,把两个人合并成了一个人,返回了一个看起来完全正常的结果。
而且它大多数时候是对的 ------单签发者的系统里,按 sub 聚合完全正确。
所以它的表现是:跑了很久,一直对,直到某个时刻有人接了第二个签发者。那之后它开始静默出错,而你没有任何地方能看到它出错。
会叫的坏东西不可怕。 这个不叫。
本篇不成立的结论(免得被过度引用)
我把话说全。
它不能证明真实生产系统已经因此合并错了用户。 需要的是一次真实事故,而我没有。本实验的令牌是我自己签的。
它也不能证明现实中一定会出现相同的 sub。 我构造了这个输入。它在协议层完全合法,后果完全可复现,但「一定会出现」是另一回事。
它也不能证明按 iss+sub 聚合在所有场景都对。 它只是 RFC 和 OIDC 明确点名的最小保证组合,不是万能钥匙。
所以这篇能支撑的说法是:这个 bug 长什么样、怎么复现、根因在哪。
不能写成「你们公司可能都有这个 bug」。
怎么改
一行:
scss
# 之前
groups[claims["sub"]].append(record)
# 之后
groups[(claims["iss"], claims["sub"])].append(record)
难的不是这一行。
难的是你得知道它错在哪一层。令牌是合法的、验签是过的、字段是有值的、代码逻辑也没写错------四样都对,你不会去怀疑任何一样。
如果你也在做多签发者的东西
三个能立刻做的检查:
- 看你按
sub聚合的地方,有没有把iss带上。 如果你的用户表只有一列sub,那它现在就是个复合主键,只是复合的第二半被扔了。 - 看你的历史数据有没有被合并过。 如果有,那不是「可能有风险」,是「已经错了」------去把两个签发者的同名用户拆开。
- 加一条断言 :同一个
sub出现在两个不同iss下时,打个警告。它不会拦住你,但会让你知道什么时候该紧张。
第一条我做不到------我的系统只有一个签发者,所以这个 bug 我至今没有真的中过 。我是签了个假的第二签发者才发现的。这件事本身也挺讽刺的:你得先造出问题,才能防住它。
附:本文引用
一、两处协议原文(都可匿名复核)
RFC 7519 §4.1.2 ,https://www.rfc-editor.org/rfc/rfc7519.txt 第 477--478 行,逐字:
The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique.
OpenID Connect Core 1.0 ,https://openid.net/specs/openid-connect-core-1_0.html:
Therefore, the only guaranteed unique identifier for a given End-User is the combination of the iss Claim and the sub Claim.
这两处能证明什么 :标准明确要求 sub 的唯一性作用域限于签发者 , 并明确点名「唯一保证 = iss + sub 的组合」。
不能证明什么 :任何一个具体系统真的会因此合并错用户。那一层靠第五节的实验。 两者缺一,任何一个单独拿出来都不足以支撑本篇的结论。
二、实验判据与原始输出
代码在 experiments/jwt-sub-scope/,原始数据见同目录的 runs/ (每轮一份、带时间戳的完整输出),读者可以直接下载复核,不必信我的复述。
判据在跑之前写死并哈希锁定:
da2621866d0c7ce8434b857bf55ba769b93057016854497344e6468b00bb159b PREREG.md
九项判据(P1--P9)全过,其中:
- P1 四枚令牌验签全部通过 ------ 排除「不合法令牌混进来」这个替代解释
- P6 阳性对照:同一
iss不同sub时没有误合并
------ 没有这一条,「合并成 1 个」可能只是聚合函数本身写坏了
- P8 错误判据全程未抛异常、未返回错误码 ------ 这是全篇的重点
- P9 过期令牌被正确拒绝 ------ 排除「非法令牌也能混进来」
三、这个实验翻车了三次,留着
初版我把 iat 写死成 1700000000(2023-11-14)。 跑到今天,四枚令牌全部已过期 , P1 得 0/4。是自检门第一项就抓住的,正式实验一步没跑。 判据一个字没改,改的是输入。
第二、第三次更难看:我那个「静默性」判据在初版里写的是 p8 = True, 理由是「上面一路跑完没抛异常」。它什么都没测 ------一个恒真的判据, 通过它不代表任何事。 修好之后挪了两次位置还是错的------每次都是别的调用点先崩了, 我准备好的那句「命题不成立」根本说不出口。 最后挪到整个程序最开头才成。
判据的调用点散布在多处时,检测必须提到所有调用点之前, 而不是提到「我以为的第一次」。没想到的地方会替你把程序打死。
所以这三轮是更正,不是重写。 判据(PREREG.md)自始至终没有改过一行, 哈希对得上;被作废的是输入和判据的调用位置 。 如果你只看最后一轮的输出,看不出这些 ------ 所以我把作废的轮次也留在 runs/ 里。
三条反向自测都验证过会失败:错误判据改成恒真(自检门红,退出码 2)、 去掉验签(退出码 2)、让错误判据抛异常(明确打印「命题不成立」,退出码 1)。
四、本文没有引用学术文献
这篇没做文献综述,也没引用任何论文或书籍。 第二节查的是协议规范原文,不是文献。 写清楚是为了避免读者以为我做过文献工作。
五、取到 / 没取到的
来源 / 状态
- RFC 7519 原文 ------ ✅ 取到,逐字复核
- OpenID Connect Core 1.0 ------ ✅ 取到,逐字复核
- JWT 最佳实践类博客 ------ ❌ 二手源,不作为锚点
取不到的东西我不用记忆去补。