多租户系统中的 OIDC:Discovery 端点与联合登录的深度实践

当一个 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 与独立的密钥,显式校验出来的。

相关推荐
IT_陈寒1 小时前
Redis持久化这个坑,我爬了一整天才出来
前端·人工智能·后端
CTA终结者1 小时前
期货量化主力换月程序怎么移仓:天勤 underlying_symbol 与任务切换
python·区块链
小小前端仔LC1 小时前
Node.js + LangChain + React:搭建个人知识库(六)- “吃什么”项目实战:从700+菜谱入库到Taro H5端JSON渲染
前端·后端
马士兵教育1 小时前
Java还有前景吗?Java+AI大模型学习路线及项目?
java·人工智能·python·学习·机器学习
程序员黑豆2 小时前
AI全栈开发之Java:怎么配置Java环境变量
前端·后端·ai编程
KaMeidebaby2 小时前
卡梅德生物技术快报|纯化重组蛋白实操详解
人工智能·python·tcp/ip·算法·机器学习
Cloud_Shy6182 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第五章 Item 30 - 32)
开发语言·人工智能·笔记·python·学习方法
苍何2 小时前
一手实测 Claude Fable 5,手搓了个 Obsidian 的 Codex 插件
后端
天佑木枫2 小时前
15天Python入门系列 · 序
开发语言·python