当一个 SaaS 产品从服务单一组织走向服务成百上千个企业客户时,身份认证会从「我有一个登录系统」演变成一个远更复杂的命题:每个企业客户都带着自己的身份系统进来,要求员工用公司账号登录你的产品。 字节的员工走字节的 Azure AD,某银行的员工走银行自建的 OIDC,小客户可能直接用 Google Workspace------你的一套系统,要同时、安全地对接这些彼此独立的身份提供方。
这正是多租户 OIDC(OpenID Connect)要解决的核心问题。本文不再泛泛介绍 OIDC,而是聚焦多租户这一具体战场,深入剖析 Discovery 端点在其中扮演的角色、租户识别(家域发现)如何驱动整个流程、issuer 校验为何是安全红线,以及一条令牌从签发到验证的完整生命周期。
一、先厘清角色:你的 SaaS 是 OP 还是 RP?
多租户 OIDC 的一切混淆,都源于没分清这个问题。OIDC 里有两个核心角色:
- OP(OpenID Provider,身份提供方):签发身份的一方,拥有用户、签发令牌、发布 Discovery 文档。
- RP(Relying Party,依赖方):信任 OP、消费身份的一方。
在企业多租户联合登录里,绝大多数情况下你的 SaaS 是 RP,而真正的 OP 是各租户的 IdP(Identity Provider,如 Azure AD、Okta、Keycloak)。
#mermaid-svg-fnoEaBmf13IpjwMS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fnoEaBmf13IpjwMS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fnoEaBmf13IpjwMS .error-icon{fill:#552222;}#mermaid-svg-fnoEaBmf13IpjwMS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fnoEaBmf13IpjwMS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fnoEaBmf13IpjwMS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fnoEaBmf13IpjwMS .marker.cross{stroke:#333333;}#mermaid-svg-fnoEaBmf13IpjwMS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fnoEaBmf13IpjwMS p{margin:0;}#mermaid-svg-fnoEaBmf13IpjwMS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fnoEaBmf13IpjwMS .cluster-label text{fill:#333;}#mermaid-svg-fnoEaBmf13IpjwMS .cluster-label span{color:#333;}#mermaid-svg-fnoEaBmf13IpjwMS .cluster-label span p{background-color:transparent;}#mermaid-svg-fnoEaBmf13IpjwMS .label text,#mermaid-svg-fnoEaBmf13IpjwMS span{fill:#333;color:#333;}#mermaid-svg-fnoEaBmf13IpjwMS .node rect,#mermaid-svg-fnoEaBmf13IpjwMS .node circle,#mermaid-svg-fnoEaBmf13IpjwMS .node ellipse,#mermaid-svg-fnoEaBmf13IpjwMS .node polygon,#mermaid-svg-fnoEaBmf13IpjwMS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fnoEaBmf13IpjwMS .rough-node .label text,#mermaid-svg-fnoEaBmf13IpjwMS .node .label text,#mermaid-svg-fnoEaBmf13IpjwMS .image-shape .label,#mermaid-svg-fnoEaBmf13IpjwMS .icon-shape .label{text-anchor:middle;}#mermaid-svg-fnoEaBmf13IpjwMS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fnoEaBmf13IpjwMS .rough-node .label,#mermaid-svg-fnoEaBmf13IpjwMS .node .label,#mermaid-svg-fnoEaBmf13IpjwMS .image-shape .label,#mermaid-svg-fnoEaBmf13IpjwMS .icon-shape .label{text-align:center;}#mermaid-svg-fnoEaBmf13IpjwMS .node.clickable{cursor:pointer;}#mermaid-svg-fnoEaBmf13IpjwMS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fnoEaBmf13IpjwMS .arrowheadPath{fill:#333333;}#mermaid-svg-fnoEaBmf13IpjwMS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fnoEaBmf13IpjwMS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fnoEaBmf13IpjwMS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fnoEaBmf13IpjwMS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fnoEaBmf13IpjwMS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fnoEaBmf13IpjwMS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fnoEaBmf13IpjwMS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fnoEaBmf13IpjwMS .cluster text{fill:#333;}#mermaid-svg-fnoEaBmf13IpjwMS .cluster span{color:#333;}#mermaid-svg-fnoEaBmf13IpjwMS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fnoEaBmf13IpjwMS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fnoEaBmf13IpjwMS rect.text{fill:none;stroke-width:0;}#mermaid-svg-fnoEaBmf13IpjwMS .icon-shape,#mermaid-svg-fnoEaBmf13IpjwMS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fnoEaBmf13IpjwMS .icon-shape p,#mermaid-svg-fnoEaBmf13IpjwMS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fnoEaBmf13IpjwMS .icon-shape .label rect,#mermaid-svg-fnoEaBmf13IpjwMS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fnoEaBmf13IpjwMS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fnoEaBmf13IpjwMS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fnoEaBmf13IpjwMS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 租户各自的OP
字节 Azure AD
某银行 自建OIDC
小客户 Google Workspace
你的 SaaS
(统一作为 RP)
业务应用 + 多租户数据隔离
这个定位决定了一个关键事实:Discovery 端点通常不在你这边,而在各个 OP 那边。 你的工作不是「发布」Discovery,而是「按租户去消费」正确的那个 Discovery。理解这一点,后面的一切才顺理成章。
例外情况:如果你的产品本身就是身份平台(你自己当 OP),那才轮到「如何生产带租户隔离的 Discovery」,本文第六节会专门讨论这种反向场景。
二、Discovery 端点在多租户中的两种形态
2.1 形态一:SaaS 作为 RP,消费各 OP 的 Discovery(主流)
每个租户在你系统里有一条配置记录,存着它对应 OP 的固定 issuer。请求到来时,你先识别租户,再取出对应 issuer,按固定规则拼出 Discovery 地址去请求:
{该租户的 issuer} + /.well-known/openid-configuration
不同租户拼出的是完全不同的 OP 的 Discovery 地址:
租户A → https://login.microsoftonline.com/aaaa-tenant/v2.0/.well-known/openid-configuration
租户B → https://accounts.google.com/.well-known/openid-configuration
#mermaid-svg-4wY9ipSynfTCazyN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4wY9ipSynfTCazyN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4wY9ipSynfTCazyN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4wY9ipSynfTCazyN .error-icon{fill:#552222;}#mermaid-svg-4wY9ipSynfTCazyN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4wY9ipSynfTCazyN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4wY9ipSynfTCazyN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4wY9ipSynfTCazyN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4wY9ipSynfTCazyN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4wY9ipSynfTCazyN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4wY9ipSynfTCazyN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4wY9ipSynfTCazyN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4wY9ipSynfTCazyN .marker.cross{stroke:#333333;}#mermaid-svg-4wY9ipSynfTCazyN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4wY9ipSynfTCazyN p{margin:0;}#mermaid-svg-4wY9ipSynfTCazyN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4wY9ipSynfTCazyN .cluster-label text{fill:#333;}#mermaid-svg-4wY9ipSynfTCazyN .cluster-label span{color:#333;}#mermaid-svg-4wY9ipSynfTCazyN .cluster-label span p{background-color:transparent;}#mermaid-svg-4wY9ipSynfTCazyN .label text,#mermaid-svg-4wY9ipSynfTCazyN span{fill:#333;color:#333;}#mermaid-svg-4wY9ipSynfTCazyN .node rect,#mermaid-svg-4wY9ipSynfTCazyN .node circle,#mermaid-svg-4wY9ipSynfTCazyN .node ellipse,#mermaid-svg-4wY9ipSynfTCazyN .node polygon,#mermaid-svg-4wY9ipSynfTCazyN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4wY9ipSynfTCazyN .rough-node .label text,#mermaid-svg-4wY9ipSynfTCazyN .node .label text,#mermaid-svg-4wY9ipSynfTCazyN .image-shape .label,#mermaid-svg-4wY9ipSynfTCazyN .icon-shape .label{text-anchor:middle;}#mermaid-svg-4wY9ipSynfTCazyN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4wY9ipSynfTCazyN .rough-node .label,#mermaid-svg-4wY9ipSynfTCazyN .node .label,#mermaid-svg-4wY9ipSynfTCazyN .image-shape .label,#mermaid-svg-4wY9ipSynfTCazyN .icon-shape .label{text-align:center;}#mermaid-svg-4wY9ipSynfTCazyN .node.clickable{cursor:pointer;}#mermaid-svg-4wY9ipSynfTCazyN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4wY9ipSynfTCazyN .arrowheadPath{fill:#333333;}#mermaid-svg-4wY9ipSynfTCazyN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4wY9ipSynfTCazyN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4wY9ipSynfTCazyN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4wY9ipSynfTCazyN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4wY9ipSynfTCazyN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4wY9ipSynfTCazyN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4wY9ipSynfTCazyN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4wY9ipSynfTCazyN .cluster text{fill:#333;}#mermaid-svg-4wY9ipSynfTCazyN .cluster span{color:#333;}#mermaid-svg-4wY9ipSynfTCazyN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4wY9ipSynfTCazyN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4wY9ipSynfTCazyN rect.text{fill:none;stroke-width:0;}#mermaid-svg-4wY9ipSynfTCazyN .icon-shape,#mermaid-svg-4wY9ipSynfTCazyN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4wY9ipSynfTCazyN .icon-shape p,#mermaid-svg-4wY9ipSynfTCazyN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4wY9ipSynfTCazyN .icon-shape .label rect,#mermaid-svg-4wY9ipSynfTCazyN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4wY9ipSynfTCazyN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4wY9ipSynfTCazyN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4wY9ipSynfTCazyN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求到达
识别租户
查租户配置库, 取出该租户固定 issuer
GET {issuer}/.well-known/openid-configuration
对应租户的 OP
返回端点 + jwks_uri + 能力元数据
缓存 (依据 Cache-Control)
注意:这里不是「转发」。是你的 SaaS 主动、按租户去请求那个租户对应 OP 的 Discovery 端点。
2.2 Discovery 文档的关键字段(多租户视角)
| 字段 | 在多租户中的意义 |
|---|---|
issuer |
每个租户的 OP 各有一个固定值,是后续 iss 校验的锚点 |
authorization_endpoint |
该租户用户被重定向去登录的地址 |
token_endpoint |
用授权码换该租户令牌的地址 |
jwks_uri |
该租户 OP 的公钥集合,验签用,严禁跨租户混用 |
id_token_signing_alg_values_supported |
该 OP 支持的签名算法 |
code_challenge_methods_supported |
是否支持 PKCE(Proof Key for Code Exchange) |
核心原则:每个租户对应的 OP,都有一套独立的端点、独立的密钥、独立的 issuer。 你必须为每个租户隔离地维护与使用这些信息。
三、多租户的真正难点:家域发现(Home Realm Discovery)
3.1 问题的本质
用户来登录时,系统还没有认证他 ------既然还不知道他是谁,又怎么知道该把他路由到哪个 OP?这就是家域发现要解决的:在认证之前,先判断这个用户属于哪个租户。
这是多租户与单一 OP 场景最本质的区别。单一 OP 时无需判断,直接走那个 OP 即可;多租户时,路由决策必须前置。
3.2 常见的识别策略
#mermaid-svg-Fo1CtRtxybzH4xDo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Fo1CtRtxybzH4xDo .error-icon{fill:#552222;}#mermaid-svg-Fo1CtRtxybzH4xDo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Fo1CtRtxybzH4xDo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Fo1CtRtxybzH4xDo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Fo1CtRtxybzH4xDo .marker.cross{stroke:#333333;}#mermaid-svg-Fo1CtRtxybzH4xDo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Fo1CtRtxybzH4xDo p{margin:0;}#mermaid-svg-Fo1CtRtxybzH4xDo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster-label text{fill:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster-label span{color:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster-label span p{background-color:transparent;}#mermaid-svg-Fo1CtRtxybzH4xDo .label text,#mermaid-svg-Fo1CtRtxybzH4xDo span{fill:#333;color:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo .node rect,#mermaid-svg-Fo1CtRtxybzH4xDo .node circle,#mermaid-svg-Fo1CtRtxybzH4xDo .node ellipse,#mermaid-svg-Fo1CtRtxybzH4xDo .node polygon,#mermaid-svg-Fo1CtRtxybzH4xDo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Fo1CtRtxybzH4xDo .rough-node .label text,#mermaid-svg-Fo1CtRtxybzH4xDo .node .label text,#mermaid-svg-Fo1CtRtxybzH4xDo .image-shape .label,#mermaid-svg-Fo1CtRtxybzH4xDo .icon-shape .label{text-anchor:middle;}#mermaid-svg-Fo1CtRtxybzH4xDo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Fo1CtRtxybzH4xDo .rough-node .label,#mermaid-svg-Fo1CtRtxybzH4xDo .node .label,#mermaid-svg-Fo1CtRtxybzH4xDo .image-shape .label,#mermaid-svg-Fo1CtRtxybzH4xDo .icon-shape .label{text-align:center;}#mermaid-svg-Fo1CtRtxybzH4xDo .node.clickable{cursor:pointer;}#mermaid-svg-Fo1CtRtxybzH4xDo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Fo1CtRtxybzH4xDo .arrowheadPath{fill:#333333;}#mermaid-svg-Fo1CtRtxybzH4xDo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Fo1CtRtxybzH4xDo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Fo1CtRtxybzH4xDo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fo1CtRtxybzH4xDo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Fo1CtRtxybzH4xDo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fo1CtRtxybzH4xDo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster text{fill:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo .cluster span{color:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Fo1CtRtxybzH4xDo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Fo1CtRtxybzH4xDo rect.text{fill:none;stroke-width:0;}#mermaid-svg-Fo1CtRtxybzH4xDo .icon-shape,#mermaid-svg-Fo1CtRtxybzH4xDo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fo1CtRtxybzH4xDo .icon-shape p,#mermaid-svg-Fo1CtRtxybzH4xDo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Fo1CtRtxybzH4xDo .icon-shape .label rect,#mermaid-svg-Fo1CtRtxybzH4xDo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fo1CtRtxybzH4xDo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Fo1CtRtxybzH4xDo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Fo1CtRtxybzH4xDo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 邮箱域名
专属子域名
URL路径
显式选择
用户访问登录页
如何识别租户?
alice@bytedance.com
按 bytedance.com 映射
bytedance.yoursaas.com
从 Host 头识别
yoursaas.com/t/bytedance
登录页列出企业供点选
查出该租户 OIDC 配置
重定向到对应 OP 的 authorization_endpoint
| 策略 | 机制 | 适用场景 |
|---|---|---|
| 邮箱域名映射 | 用户先输邮箱,按域名查 IdP | 最通用,员工邮箱即公司域名时最自然 |
| 专属子域名 | 每租户分配 tenant.yoursaas.com |
租户希望有品牌化入口 |
| URL 路径 | yoursaas.com/t/{tenant} |
实现简单,无需多域名证书 |
| 显式选择 | 登录页让用户点选所属企业 | 租户数量可控、用户清楚自己归属 |
实践中常组合使用:邮箱域名作主路径,子域名作租户专属入口,兜底再提供显式选择。
四、租户配置:连接 RP 与各 OP 的中枢
多租户 SaaS 内部维护一张「租户 IdP 配置表」,它是家域发现的查询目标,也是后续 OIDC 流程的参数来源:
| 字段 | 示例 | 用途 |
|---|---|---|
tenant_id |
bytedance | 租户唯一标识,绑定会话与数据 |
issuer |
https://login.microsoftonline.com/aaaa | 固定的 OP 标识,拼 Discovery、校验 iss |
client_id |
saas-app-xxx | 在该 OP 注册的客户端 ID,用于 aud 校验 |
client_secret |
(加密存储) | 在 token_endpoint 换取令牌 |
email_domains |
bytedance.com | 家域发现的映射键 |
enabled |
true | 是否启用该租户登录 |
运行时的数据流:识别租户 → 查这张表 → 用 issuer 拉 Discovery(缓存)→ 用 client_id/secret 走授权码流程。
五、端到端流程:一次完整的多租户登录
把家域发现、Discovery 消费、令牌验证串起来,一次完整登录如下:
字节IdP (OP) 租户配置库 你的SaaS (RP) 字节员工 字节IdP (OP) 租户配置库 你的SaaS (RP) 字节员工 #mermaid-svg-P1Nbsn6uwViJgNMU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-P1Nbsn6uwViJgNMU .error-icon{fill:#552222;}#mermaid-svg-P1Nbsn6uwViJgNMU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-P1Nbsn6uwViJgNMU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-P1Nbsn6uwViJgNMU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-P1Nbsn6uwViJgNMU .marker.cross{stroke:#333333;}#mermaid-svg-P1Nbsn6uwViJgNMU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-P1Nbsn6uwViJgNMU p{margin:0;}#mermaid-svg-P1Nbsn6uwViJgNMU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-P1Nbsn6uwViJgNMU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-P1Nbsn6uwViJgNMU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-P1Nbsn6uwViJgNMU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-P1Nbsn6uwViJgNMU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-P1Nbsn6uwViJgNMU .sequenceNumber{fill:white;}#mermaid-svg-P1Nbsn6uwViJgNMU #sequencenumber{fill:#333;}#mermaid-svg-P1Nbsn6uwViJgNMU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-P1Nbsn6uwViJgNMU .messageText{fill:#333;stroke:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-P1Nbsn6uwViJgNMU .labelText,#mermaid-svg-P1Nbsn6uwViJgNMU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .loopText,#mermaid-svg-P1Nbsn6uwViJgNMU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-P1Nbsn6uwViJgNMU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-P1Nbsn6uwViJgNMU .noteText,#mermaid-svg-P1Nbsn6uwViJgNMU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-P1Nbsn6uwViJgNMU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-P1Nbsn6uwViJgNMU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-P1Nbsn6uwViJgNMU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-P1Nbsn6uwViJgNMU .actorPopupMenu{position:absolute;}#mermaid-svg-P1Nbsn6uwViJgNMU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-P1Nbsn6uwViJgNMU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-P1Nbsn6uwViJgNMU .actor-man circle,#mermaid-svg-P1Nbsn6uwViJgNMU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-P1Nbsn6uwViJgNMU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段一 家域发现 阶段二 Discovery (结果可缓存) 校验返回 issuer == 配置中固定 issuer 阶段三 授权码 + PKCE 登录 阶段四 验证与建会话 按 kid 取公钥验签iss == 字节固定 issueraud == 自己的 client_id 输入 alice@bytedance.com按域名 bytedance.com 查租户租户=bytedance, issuer/client_id/secretGET {issuer}/.well-known/openid-configuration端点 + jwks_uri + 能力重定向到 authorization_endpoint字节登录页用公司账号认证回调 authorization codetoken_endpoint 换 ID TokenID Token (+ Access Token)在 bytedance 租户上下文创建会话登录成功, 进入字节租户空间
每个阶段都有不可省略的动作:家域发现确定路由,Discovery 提供端点与公钥,授权码流程完成认证,而最后一阶段的三重校验 + 会话绑定租户,才是多租户安全的真正落点。
六、反向场景:当 SaaS 自己就是 OP
如果你的产品本身是身份平台(你签发身份给租户的应用使用),那你就是 OP,需要生产带租户隔离的 Discovery。主流设计是「每租户独立 issuer,用路径区分」:
https://auth.yoursaas.com/tenant-a/.well-known/openid-configuration
→ 返回 issuer: https://auth.yoursaas.com/tenant-a
https://auth.yoursaas.com/tenant-b/.well-known/openid-configuration
→ 返回 issuer: https://auth.yoursaas.com/tenant-b
服务端根据 URL 中的租户段动态生成对应元数据。这正是 Azure AD 的做法------login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration。它不是转发,而是你自己按租户上下文动态生成并返回。
另一种是「单一 issuer,所有租户共用,靠 token 内某个 claim(如 tid)区分」,对应 Azure AD 的 common/organizations 端点思路。前者隔离性强、推荐;后者管理简单但租户边界依赖 claim。
七、issuer 校验:固定性与一致性自检的统一
这是多租户 OIDC 最易被误解的一点:issuer 既是固定的,又要「等于请求基地址」------这两者不矛盾,是同一件事。
7.1 issuer 始终固定
对某个租户的 OP,其 issuer 是一个固定不变的字符串,写死在你的租户配置里。它不会随请求变化。
7.2 「等于请求基地址」是一条防伪自检
那条规则的真正含义是:你拉 Discovery 用的地址,本身就是基于你已知的固定 issuer 拼出来的;拉回的文档里的 issuer,必须等于这个你预期的固定值。
#mermaid-svg-LbgyYiTmak1Q5ITb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LbgyYiTmak1Q5ITb .error-icon{fill:#552222;}#mermaid-svg-LbgyYiTmak1Q5ITb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LbgyYiTmak1Q5ITb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LbgyYiTmak1Q5ITb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LbgyYiTmak1Q5ITb .marker.cross{stroke:#333333;}#mermaid-svg-LbgyYiTmak1Q5ITb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LbgyYiTmak1Q5ITb p{margin:0;}#mermaid-svg-LbgyYiTmak1Q5ITb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster-label text{fill:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster-label span{color:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster-label span p{background-color:transparent;}#mermaid-svg-LbgyYiTmak1Q5ITb .label text,#mermaid-svg-LbgyYiTmak1Q5ITb span{fill:#333;color:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb .node rect,#mermaid-svg-LbgyYiTmak1Q5ITb .node circle,#mermaid-svg-LbgyYiTmak1Q5ITb .node ellipse,#mermaid-svg-LbgyYiTmak1Q5ITb .node polygon,#mermaid-svg-LbgyYiTmak1Q5ITb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LbgyYiTmak1Q5ITb .rough-node .label text,#mermaid-svg-LbgyYiTmak1Q5ITb .node .label text,#mermaid-svg-LbgyYiTmak1Q5ITb .image-shape .label,#mermaid-svg-LbgyYiTmak1Q5ITb .icon-shape .label{text-anchor:middle;}#mermaid-svg-LbgyYiTmak1Q5ITb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LbgyYiTmak1Q5ITb .rough-node .label,#mermaid-svg-LbgyYiTmak1Q5ITb .node .label,#mermaid-svg-LbgyYiTmak1Q5ITb .image-shape .label,#mermaid-svg-LbgyYiTmak1Q5ITb .icon-shape .label{text-align:center;}#mermaid-svg-LbgyYiTmak1Q5ITb .node.clickable{cursor:pointer;}#mermaid-svg-LbgyYiTmak1Q5ITb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LbgyYiTmak1Q5ITb .arrowheadPath{fill:#333333;}#mermaid-svg-LbgyYiTmak1Q5ITb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LbgyYiTmak1Q5ITb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LbgyYiTmak1Q5ITb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LbgyYiTmak1Q5ITb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LbgyYiTmak1Q5ITb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LbgyYiTmak1Q5ITb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster text{fill:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb .cluster span{color:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LbgyYiTmak1Q5ITb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LbgyYiTmak1Q5ITb rect.text{fill:none;stroke-width:0;}#mermaid-svg-LbgyYiTmak1Q5ITb .icon-shape,#mermaid-svg-LbgyYiTmak1Q5ITb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LbgyYiTmak1Q5ITb .icon-shape p,#mermaid-svg-LbgyYiTmak1Q5ITb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LbgyYiTmak1Q5ITb .icon-shape .label rect,#mermaid-svg-LbgyYiTmak1Q5ITb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LbgyYiTmak1Q5ITb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LbgyYiTmak1Q5ITb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LbgyYiTmak1Q5ITb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不等
不等
配置里固定信任的 issuer
(写死)
据此拼出 Discovery URL 请求
返回文档 issuer 必须 == 预期固定值
ID Token 的 iss 必须 == 同一固定值
拒绝, 可能被偷换
正例与反例:
正常: 配置信任 https://login.bytedance.com
拼出 https://login.bytedance.com/.well-known/openid-configuration
拉回 issuer = https://login.bytedance.com ✅ 与预期一致
异常: 配置被篡改指向 https://evil.com
拉回 issuer = https://evil.com
但代码写死信任 bytedance → 不匹配 → 拒绝
「等于请求基地址」是手段,「等于你固定信任的那个 issuer」才是目的。因为请求地址本就是用固定 issuer 拼的,两种说法等价。校验的本质是:确认我实际拿到的,就是我预期信任的那个固定值,防止有人中途偷换。
八、多租户身份隔离的安全红线
多租户最大的风险不是登不上,而是租户之间的身份越界------A 租户的令牌被 B 租户接受,或 A 租户用户访问到 B 租户数据。以下红线缺一不可:
| 红线 | 说明 | 不做的后果 |
|---|---|---|
| 按租户校验 iss | token 的 iss 必须等于该租户固定 issuer |
接受别家 OP 的令牌 |
| 校验 aud(受众) | aud 必须是你在该 OP 注册的 client_id |
令牌挪用、跨应用重放 |
| 公钥按租户隔离 | 严禁用 A 租户 OP 的公钥验 B 租户的 token | IdP 混淆攻击(Mix-Up Attack) |
| 会话绑定租户 | 认证后的会话标记所属租户 | 越权访问其他租户数据 |
| 配置与密钥隔离 | 各租户 client_secret 独立加密存储 | 一租户泄露波及全体 |
| 强制 HTTPS | 所有端点走 TLS | 端点地址被中间人篡改 |
IdP 混淆攻击的具象
#mermaid-svg-0MnQvKdnNwCJaTIJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0MnQvKdnNwCJaTIJ .error-icon{fill:#552222;}#mermaid-svg-0MnQvKdnNwCJaTIJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0MnQvKdnNwCJaTIJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .marker.cross{stroke:#333333;}#mermaid-svg-0MnQvKdnNwCJaTIJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0MnQvKdnNwCJaTIJ p{margin:0;}#mermaid-svg-0MnQvKdnNwCJaTIJ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster-label text{fill:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster-label span{color:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster-label span p{background-color:transparent;}#mermaid-svg-0MnQvKdnNwCJaTIJ .label text,#mermaid-svg-0MnQvKdnNwCJaTIJ span{fill:#333;color:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .node rect,#mermaid-svg-0MnQvKdnNwCJaTIJ .node circle,#mermaid-svg-0MnQvKdnNwCJaTIJ .node ellipse,#mermaid-svg-0MnQvKdnNwCJaTIJ .node polygon,#mermaid-svg-0MnQvKdnNwCJaTIJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .rough-node .label text,#mermaid-svg-0MnQvKdnNwCJaTIJ .node .label text,#mermaid-svg-0MnQvKdnNwCJaTIJ .image-shape .label,#mermaid-svg-0MnQvKdnNwCJaTIJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-0MnQvKdnNwCJaTIJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .rough-node .label,#mermaid-svg-0MnQvKdnNwCJaTIJ .node .label,#mermaid-svg-0MnQvKdnNwCJaTIJ .image-shape .label,#mermaid-svg-0MnQvKdnNwCJaTIJ .icon-shape .label{text-align:center;}#mermaid-svg-0MnQvKdnNwCJaTIJ .node.clickable{cursor:pointer;}#mermaid-svg-0MnQvKdnNwCJaTIJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .arrowheadPath{fill:#333333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0MnQvKdnNwCJaTIJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0MnQvKdnNwCJaTIJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0MnQvKdnNwCJaTIJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster text{fill:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ .cluster span{color:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0MnQvKdnNwCJaTIJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0MnQvKdnNwCJaTIJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-0MnQvKdnNwCJaTIJ .icon-shape,#mermaid-svg-0MnQvKdnNwCJaTIJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0MnQvKdnNwCJaTIJ .icon-shape p,#mermaid-svg-0MnQvKdnNwCJaTIJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0MnQvKdnNwCJaTIJ .icon-shape .label rect,#mermaid-svg-0MnQvKdnNwCJaTIJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0MnQvKdnNwCJaTIJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0MnQvKdnNwCJaTIJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0MnQvKdnNwCJaTIJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未校验
严格校验
攻击者持有
A租户合法令牌
在B租户登录流程中冒用
系统是否校验
iss == B租户固定issuer?
把A身份当B身份接受
越权成功
iss 是A的, 不等于B → 拒绝
这正是「每租户独立校验固定 issuer」存在的根本理由------它把「我以为在和哪个租户的 OP 通信」与「令牌实际由哪个 OP 签发」锁死成同一身份。
九、工程实践要点
| 维度 | 实践建议 |
|---|---|
| Discovery 缓存 | 按 OP 分别缓存元数据与 JWKS,依据 Cache-Control;密钥轮换时按 kid 未命中再刷新 |
| JWKS 多公钥 | OP 的 jwks_uri 返回密钥数组,验签时按 JWT 头部 kid 匹配,天然支持轮换 |
| 家域发现兜底 | 邮箱域名为主,辅以子域名与显式选择,处理未知域名的降级路径 |
| 租户配置热更新 | 新增/变更租户 IdP 配置应无需重启,Discovery 缓存可主动失效 |
| Access Token 处理 | JWT 格式本地验签;不透明令牌走 Introspection 端点(RFC 7662) |
| 可观测性 | 按租户维度记录登录成功率、Discovery 拉取失败、验签失败,便于定位单租户问题 |
结语
多租户 OIDC 的复杂度,不在 OIDC 协议本身,而在「一套系统如何安全地与 N 个彼此独立的身份提供方共处」。理清这一点后,整个体系会变得清晰:
- 角色上,你的 SaaS 多数时候是 RP,去消费各 OP 的 Discovery,而非发布自己的;
- 流程上,家域发现是前置关键------先判断用户属于哪个租户,才能路由到正确的 OP;
- 配置上,每个租户的 issuer、client、密钥都独立维护;
- 安全上,issuer 始终固定,校验的是「实际拿到的是否就是预期信任的那个固定值」,并以此为核心防住 IdP 混淆与租户越界。
一句话收束:多租户身份的安全,从来不是假设出来的,而是在每一次登录、对每一个租户、用其固定的 issuer 与独立的密钥,显式校验出来的。