KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 联邦与多租户:一个应用,几家 IdP — keel 龙骨
前四章都只有一个 IdP。真实场景里有两个方向会把这个前提打破: 你的系统卖给多家客户(每家带自己的 IdP),或者你自己在多个系统之间流转(同一套代码接多家)。 这一章讲打破之后新增的那几件事:怎么区分来源、sub 还能不能用、账号怎么关联。
前四章都只有一个 IdP。真实场景里有两个方向会把这个前提打破:
你的系统卖给多家客户(每家带自己的 IdP),或者你自己在多个系统之间流转(同一套代码接多家)。
这一章讲打破之后新增的那几件事:怎么区分来源、sub还能不能用、账号怎么关联。
一、现场:第二家客户带来的三个新问题
第一家客户接进来了,跑得很好。第二家客户来了,也是接自己的 IdP。于是:
- 怎么知道这次回跳是谁发的? 两家客户的回跳都落到同一个
/callback。
如果代码里只有一份client_id/ 一份密钥,第二家一接就会串。 sub还能当用户主键吗? 第一家客户里alice的sub是8b0e33a7-...,
第二家客户里另一个alice的sub完全可能也是同一个值——两个人会被合成一个人。- 离职回收谁来做? 客户 A 用他们自己的 HR 系统停用了某个人,
你的系统怎么知道他该退出去?这不再是一个技术细节,而是一条流程契约。
三个问题都属于同一件事:从「一个信任域」变成「多个信任域」。
第 01 章把 realm 定义成「一个信任域」,这一章就是它的后果。
二、怎么区分来源:三道可用的依据
| 依据 | 从哪来 | 适合做什么 |
|---|---|---|
iss |
令牌里的 iss claim;回跳 URL 上的 iss 参数(RFC 9207) |
首选:确认这次答复来自哪一个 IdP |
| realm / 端点前缀 | issuer 本身的分段(例如 /realms/tenant-a) |
人看得懂的路由与配置分组 |
client_id / 回跳地址 |
你为每家登记的那一份配置 | 按 issuer 分表配置(各家的密钥、回跳、属性映射都不一样) |
一个可用的最小做法是:把配置按 issuer 建索引,回跳时先用 iss 选配置,再用那份配置去换 token。
配置表: issuer -> { client_id, client_secret, redirect_uri, jwks }
回跳时: iss 参数 -> 选中配置 -> 用这份配置换 token 并验 ID Token
注意这里的顺序:先定来源,再决定去哪个令牌端点。反过来做(先拿 code 去换、
换回来再看 iss)就晚了一步——code 已经被送到一个可能是攻击者的端点上了。
RFC 9700 把这类攻击叫 Mix-Up,并把「在授权响应里带上发行者标识」列为首选缓解手段之一。
还有一个必须一起看的细节:给每家 IdP 用不同的回跳地址(例如/callback/tenant-a、/callback/tenant-b)是第二重保险。它让「回跳落到了哪个地址」
本身就携带了来源信息,即使某个 IdP 不支持 iss 参数也能路由。
三、sub 的稳定范围:比你想的窄
flowchart TD
subgraph IDPA["客户 A 的 IdP(issuer = /realms/tenant-a)"]
SA["alice 的 sub = a-8b0e33a7"]
end
subgraph IDPB["客户 B 的 IdP(issuer = /realms/tenant-b)"]
SB["alice 的 sub = b-c41f90d2"]
end
subgraph APP["你的系统(一个应用,一个 /callback)"]
R1["按 iss 选配置"]
R2["账号关联表<br/>(issuer, sub) -> 本地账号"]
L[("本地账号<br/>user-0001")]
end
SA -->|"① ID Token:iss + sub"| R1
SB -->|"② ID Token:iss + sub"| R1
R1 -->|"③ 用这一对去查关联"| R2
R2 --> L
R1 -.->|"④ iss 不认识:拒绝"| X1["invalid_issuer / unknown_issuer"]
R2 -.->|"⑤ (iss, sub) 没有关联记录:不能自动登录"| X2["要求先关联账号"]
L -.->|"⑥ 两家客户里出现同一个 email:不能当成同一个人"| L
配套示例里同一个 alice 分别从两家 IdP 登录,拿到的是两个不同的 sub:
=== 情形一:同一个 Alice,分别从两家的 IdP 登录 ===
http://localhost:8081/realms/tenant-a
sub = a-8b0e33a7 | aud = work-app | iss = http://localhost:8081/realms/tenant-a
http://localhost:8082/realms/tenant-b
sub = b-c41f90d2 | aud = work-app | iss = http://localhost:8082/realms/tenant-b
两家的 sub 相同吗 = False
⇒ 同一个自然人,在两个 IdP 下是两个不同的 sub。sub 只在「某一家 IdP 之内」稳定。
准确的口径是这样三句话:
sub在一个 IdP(issuer)之内稳定——这就是 04 章「两个应用sub相同」能成立的原因;- 跨 IdP 不保证相同,也不保证不同——它只是一个字符串,两家各自生成;
- 同一个 IdP 之内,
sub也未必跨客户端一致:discovery 文档里subject_types_supported = ["public", "pairwise"]说的是两种模式,public下同一个用户在多个客户端拿到同一个sub(本课主案例就是这样),pairwise下每个客户端会拿到不同的sub——一旦对端改成pairwise,
任何「靠sub跨应用认人」的写法都会失效。
所以正确的做法只有一条:本系统自己发一个稳定的本地账号 id,
把 (issuer, sub) 映射过去。
四、账号关联:主键选谁
=== 情形二:本系统自己维护的账号关联表 ===
(issuer, sub) -> 本地账号
(http://localhost:8081/realms/tenant-a, a-8b0e33a7) -> user-0001
(http://localhost:8082/realms/tenant-b, b-c41f90d2) -> user-0001
⇒ 主键是「issuer + sub」这一对:单独一个 sub 不够,email 也不能当主键。
为什么不能用 email:
- 会变(改名、换域、离职后再入职);
- 可能重复(不同租户里很可能都有
admin@这类账号); - 可能未经校验(
email_verified为false时它只是一句自称); - 多家 IdP 之间不可信:客户 A 的 IdP 说自己是
alice@example.com,
客户 B 的 IdP 也可以这么说——它们之间没有仲裁者。
关联表本身的形态建议:
identity_link(id, local_account_id, issuer, subject, linked_at, source)
UNIQUE(issuer, subject) -- 一家 IdP 的一个 sub 只能挂到一个本地账号
INDEX(local_account_id) -- 一个本地账号可以挂多家 IdP
source 那一列值得留:它记录「这条关联是自动建的还是管理员批的」——
自动关联(例如「邮箱相同就合并」)是安全事故的高发点,事后要能查出来。
五、企业接入清单
把「接一家客户」当成一次交付,下面六项缺一项都会在某个时刻变成故障。
| # | 事项 | 要交换 / 确认什么 | 常见的坑 |
|---|---|---|---|
| 1 | 元数据交换 | OIDC:discovery 地址 + 各家 client 登记;SAML:元数据文件(含签名证书) | 只拿到文档没拿到生产环境的地址;测试环境与生产环境的 issuer 不同 |
| 2 | claims 映射 | 用哪个 claim 做显示名/邮箱/部门/组;组是否要转成本系统的角色 | 把 preferred_username 当唯一标识;组名带租户前缀未处理 |
| 3 | 账号关联 | 首次登录怎么建账号;已存在的账号怎么关联;谁来批 | 自动按邮箱合并(见上一节) |
| 4 | 离职回收 | 停用如何通知:SCIM 同步、back-channel logout、定期比对 | 只做登录时校验(「不再登录就没事」)——在职期间被停用的人仍能继续用 |
| 5 | 测试与灰度 | 测试账号、联调环境、切换时机 | 直接在生产上首登;客户侧没准备测试账号,联调全靠真人 |
| 6 | 运维约定 | 证书 / 密钥的轮换时间表与通知方式;故障时的联系人 | 客户单方面轮换签名证书,你这边全量验签失败 |
第 4 项最容易低估。「登录时校验一次」只挡住了「下一次登录」,
而一个已经登录的会话在会话过期前不受影响。三种回收手段的强度不同:
最弱:登录时校验(只在下次登录生效)
中等:短会话寿命 + back-channel logout 通知(能清掉应用侧会话)
最强:账号停用 + 会话批量失效 + 令牌短寿命(窗口最小)
六、与《权限与多租户》的接缝
这是本课与《权限与多租户》
唯一相遇的地方,值得单独说清。
那门课里有一个事实:同一个人在两个租户里是两个不同的身份,
角色、数据范围都不一样。本课带来的是另一个事实:同一个人可能从多家 IdP 进来。
两者叠在一起,会出现三个层级的 id:
| 层级 | 例子 | 由谁定义 | 稳定范围 |
|---|---|---|---|
| IdP 内的用户标识 | sub |
客户的 IdP | 某一家 IdP 之内 |
| 本系统的账号 | user-0001 |
你的系统(关联表) | 你的系统之内 |
| 租户内身份 | 成员记录(角色、数据范围) | 你的系统(授权那一侧) | 某个租户之内 |
一句话的分工:sub 是别人的说法,user_id 是你的说法,成员身份是授权课的说法。
把三者混作一个字段,就会出现「换了个 IdP 就多出一个用户」或者
「同一个人的两个租户身份被合并」这类问题。
动手:可观察结果
cd courses/identity/sso-engineering/course/project
python examples/08_two_idps_and_account_linking.py
三段输出对应三节:
- 情形一:同一个
alice,两家 IdP 给两个sub; - 情形二:关联表的键是
(issuer, sub); - 情形三:把 A 家的回跳塞给配置在 B 家上的应用 →
invalid_issuer。
故障注入
| 破坏 | 改法 | 预期现象 | 根因 |
|---|---|---|---|
| 把 A 家的回跳给 B 家的配置 | 示例 08 情形三 | invalid_issuer |
少做 iss 比对 |
| 用 B 家的配置验 A 家的 ID Token | 测试 test_each_idp_has_its_own_signing_key |
unknown kid |
两家的密钥与 kid 命名空间是独立的 |
直接用 sub 当本地主键 |
把两家 IdP 的 sub 都塞进同一张唯一索引 |
两家客户里的同名用户互相顶掉 | 主键少了一维 |
| 按邮箱自动合并账号 | 两家 IdP 都给同一个 email |
两个人被合成一个账号 | 缺少仲裁者(见第四节) |
| 只做登录时校验的离职回收 | 登录后停用对方账号,继续用旧会话 | 会话过期前一切正常 | 回收点选错了 |
前两行有配套的示例与测试可以直接跑;后三行是设计级注入——
它们改的不是某一行代码,而是数据模型或流程,所以要落到自己的系统上验证。
生产边界
| 教学里这样 | 真实部署要换成 | 不换会出什么事 |
|---|---|---|
| 两家 IdP 的配置写在同一个进程里 | 配置按 issuer 落库/落配置中心,可独立变更与灰度 | 新增一家客户要发版;改一家会波及另一家 |
共用一个 /callback |
按 issuer 分回跳地址(或按 issuer 分路由) | 不同 IdP 的回跳混在一处,路由错误只能靠现象反推 |
| 内存里的关联表 | 带 UNIQUE(issuer, subject) 约束的持久化表 + 变更审计 |
关联关系无法追溯;并发首登可能建出两个账号 |
| 只支持 OIDC | 按客户能力同时支持 SAML(同一套账号关联,两套协议适配) | 遇到只有 SAML 的大客户时接入层要重做 |
| 手动建立/解除关联 | 有明确的所有者与审批流,并能回答「这条关联是谁建的」 | 自动合并产生的越权没人能复盘 |
| 停用只靠对方通知 | 定期的账号比对(差集)作为兜底 | 通知丢了就永远留着权限 |
自测题
- 为什么「先拿 code 去换 token、换回来再看
iss」是错的?正确顺序是什么? sub在什么范围内稳定?为什么它不能直接当你的账号主键?subject_types_supported里的pairwise会带来什么额外影响?- 为什么不能用
email做账号关联的主键?说出至少三条理由。 - 客户停用了某个人,但你的系统里他还开着会话。列出三种回收手段,从弱到强排,
并说出各自的窗口期。 - 用一句话说清
sub/ 本地user_id/ 租户内成员身份三者的分工,
以及《权限与多租户》那门课管的是哪一个。
现在能解释什么
- 一个应用接多家 IdP 时的三道区分依据,以及「先定来源再换 token」的顺序为什么重要;
sub的真实稳定范围(一家 IdP 之内,且受public/pairwise模式影响),
以及为什么本系统必须自己发账号 id;- 账号关联表为什么以
(issuer, sub)为键,为什么email不能当主键; - 企业接入清单的六项,以及离职回收的三种强度;
- 本课与《权限与多租户》的接缝:
sub/user_id/ 租户内身份是三层的三件事。