KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 联邦与多租户:一个应用,几家 IdP — keel 龙骨

前四章都只有一个 IdP。真实场景里有两个方向会把这个前提打破: 你的系统卖给多家客户(每家带自己的 IdP),或者你自己在多个系统之间流转(同一套代码接多家)。 这一章讲打破之后新增的那几件事:怎么区分来源、sub 还能不能用、账号怎么关联。

前四章都只有一个 IdP。真实场景里有两个方向会把这个前提打破:
你的系统卖给多家客户(每家带自己的 IdP),或者你自己在多个系统之间流转(同一套代码接多家)。
这一章讲打破之后新增的那几件事:怎么区分来源、sub 还能不能用、账号怎么关联。


一、现场:第二家客户带来的三个新问题

第一家客户接进来了,跑得很好。第二家客户来了,也是接自己的 IdP。于是:

  1. 怎么知道这次回跳是谁发的? 两家客户的回跳都落到同一个 /callback。
    如果代码里只有一份 client_id / 一份密钥,第二家一接就会串。
  2. sub 还能当用户主键吗? 第一家客户里 alice 的 sub 是 8b0e33a7-...,
    第二家客户里另一个 alice 的 sub 完全可能也是同一个值——两个人会被合成一个人。
  3. 离职回收谁来做? 客户 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 之内」稳定。

准确的口径是这样三句话:

  1. sub 在一个 IdP(issuer)之内稳定——这就是 04 章「两个应用 sub 相同」能成立的原因;
  2. 跨 IdP 不保证相同,也不保证不同——它只是一个字符串,两家各自生成;
  3. 同一个 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:

关联表本身的形态建议:

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

三段输出对应三节:

  1. 情形一:同一个 alice,两家 IdP 给两个 sub;
  2. 情形二:关联表的键是 (issuer, sub);
  3. 情形三:把 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 的大客户时接入层要重做
手动建立/解除关联 有明确的所有者与审批流,并能回答「这条关联是谁建的」 自动合并产生的越权没人能复盘
停用只靠对方通知 定期的账号比对(差集)作为兜底 通知丢了就永远留着权限

自测题

  1. 为什么「先拿 code 去换 token、换回来再看 iss」是错的?正确顺序是什么?
  2. sub 在什么范围内稳定?为什么它不能直接当你的账号主键?
    subject_types_supported 里的 pairwise 会带来什么额外影响?
  3. 为什么不能用 email 做账号关联的主键?说出至少三条理由。
  4. 客户停用了某个人,但你的系统里他还开着会话。列出三种回收手段,从弱到强排,
    并说出各自的窗口期。
  5. 用一句话说清 sub / 本地 user_id / 租户内成员身份三者的分工,
    以及《权限与多租户》那门课管的是哪一个。

现在能解释什么

进入 keel 阅读