本文以已有 Azure AD B2C 租户为例。新项目应同时评估 Microsoft Entra External ID,因为 Azure AD B2C 已不再向新客户服务。
适用场景:企业对外开放 API,自身不维护用户账号密码,而是代理客户企业的 Microsoft Entra ID (原Azure AD) / Azure AD B2C 完成认证,再向下游客户端(第三方应用)签发标准 OAuth2/OIDC token。
文档中将出现三个角色:
认证服务器 - 企业自己实现的 OAuth/OIDC 认证服务;为方便理解,文中的 我们 或者 Broker即指代这里的企业;
Microsoft Entra ID / Azure AD B2C - 用户实际认证的服务;
客户端 - 最终用户使用的 web/ app;
这其实是我之前工作中独立完成的一个小项目,本文作为一个工作总结。
某公司在特定工业技术领域对外开放一组 API,供客户调用以完成工作流/流水线的定义与设计。客户企业的员工(最终用户)需要:
client_id(app id),用以标识其应用。公司希望员工可以直接使用自己企业已有的 Microsoft Entra ID 账号登录,而不需要公司另外维护一套账号密码体系。
| 需求 | 说明 |
|---|---|
| 不维护用户密码 | 不存储、不校验任何用户的账号/密码 |
| 联合登录 | 用户使用自身企业的 Microsoft Entra ID(通过 Azure AD B2C)账号登录 |
| 标准协议对外 | 对下游客户端,提供标准 OAuth2 Authorization Code + PKCE 流程 |
| 多租户/多 client | 支持多个客户企业各自申请 client_id,数据与权限相互隔离 |
| SSO(单点登录) | 同一用户在同一浏览器内访问不同已登录应用时无需重复登录 |
| Token 全生命周期管理 | 签发、校验、续期(refresh)、吊销 access_token / refresh_token |
下游客户端的 OIDC SDK 在校验 id_token 时,会检查 iss(签发者)是否与其在 Discovery 文档中配置的一致。如果直接把 B2C 签发的 id_token 转发给客户端,iss 会指向 B2C 的地址,而客户端配置的是我们自己的 issuer, 校验必然失败。
因此必须由自己的 OAuth 服务器重新签发一套 id_token / access_token,这是本架构的核心设计决策。
这是一种成熟的架构模式,业界称为 Identity Broker(身份代理) 或 Federation Proxy(联合认证代理),Keycloak 的 "Identity Brokering"、Auth0 的 "Enterprise Connections" 都是同一模式的商业实现。
认证服务器同时扮演两个角色:
两段 OAuth/OIDC 会话是完全独立的两次协议交互,由我们的服务器在中间做身份桥接与状态维护。
必须实现的 OAuth2/OIDC 端点:
/.well-known/openid-configuration)/authorize)—— 仅支持 Authorization Code + PKCE 模式/token)—— 处理 authorization_code 和 refresh_token 两种 grant_type/.well-known/jwks.json)—— 供客户端校验 token 签名/userinfo)—— 标准 OIDC SDK 常规调用/revoke,RFC 7009)/logout)—— 处理登出,含与 B2C 的联动登出必需的基础设施:
这里只支持 Microsoft Entra ID 的登陆,对于我们 Broker 来说,客户自己的账号系统必须是 Microsoft Entra ID。对于有其他系统或者自建 AD FS 的客户,如果要支持授权,则需要确定识别用户来自哪个客户的策略。通常有以下方法:
flowchart LR
subgraph 客户企业
EU[最终用户/员工]
APP[客户端应用<br/>client_id]
end
subgraph 我们的OAuth服务器
AUTH[/authorize/]
TOKEN[/token/]
SESSION[(登录 Session Store)]
TXN[(临时 Transaction 存储)]
end
subgraph 微软侧
B2C[Azure AD B2C]
AAD[客户企业 Microsoft Entra ID]
end
EU -->|1.点击登录| APP
APP -->|2.跳转 /authorize| AUTH
AUTH -->|3.无session,跳转| B2C
B2C -->|4.联合到| AAD
AAD -->|5.用户完成认证| B2C
B2C -->|6.回调携带 authorization code| AUTH
AUTH -->|7.后端使用 code 请求 B2C token endpoint| B2C
B2C -->|8.返回 id_token/access_token| AUTH
AUTH -->|9.校验上游 id_token| AUTH
AUTH -->|10.建立登录session| SESSION
AUTH -->|11.重定向携带code| APP
APP -->|12.code+code_verifier换token| TOKEN
TOKEN -->|13.签发自有id_token/access_token| APP
sequenceDiagram
participant U as 用户浏览器
participant C as "客户端应用(client_id=A1)"
participant OP as 我们的OAuth服务器
participant B2C as Azure AD B2C
C->>OP: GET /authorize?client_id=A1&redirect_uri=..&state=S1&code_challenge=C1&nonce=N1
OP->>OP: 校验client_id/redirect_uri白名单
OP->>OP: 检查本地登录session cookie
alt 无登录session
OP->>OP: 保存transaction上下文(S1,C1,N1,client_id,redirect_uri) => txn_id
OP->>OP: 生成内部state=S2, nonce=N2
OP->>B2C: 重定向 /authorize?state=S2&nonce=N2&response_type=code&code_challenge=C2
B2C->>U: 展示登录页(联合到客户企业AAD)
U->>B2C: 完成账号密码/MFA认证
B2C->>OP: 回调携带code及state=S2
OP->>B2C: code + code_verifier=C2,用code换取B2C的id_token
OP->>OP: 校验id_token(签名/iss/aud/exp/nonce=N2)
OP->>OP: 用txn_id取回原始上下文(S1,C1,N1)
OP->>OP: 建立登录session,写HttpOnly Cookie
else 已有登录session - SSO命中
OP->>OP: 直接使用session中的用户身份
end
OP->>OP: 生成authorization code(与C1绑定),TTL短
OP->>C: 302重定向携带code + 原始state=S1
C->>OP: POST /token (code, code_verifier, client_id)
OP->>OP: 校验code_verifier与C1匹配(PKCE)
OP->>OP: 校验code未被使用过(一次性)
OP->>C: 签发自有id_token(iss=我们自己,nonce=N1)+access_token+refresh_token
其中:
C1:下游客户端 → Broker (我们的OAuth服务器);
C2:Broker → 上游 IdP。
查看大图:👉 点击此处在新页面中打开时序图
kid 机制。client_id、client_secret_hash、redirect_uris(白名单,精确匹配)、allowed_scopes。/.well-known/openid-configuration 返回 issuer、各端点 URL、支持的签名算法、支持的 scope 等。/.well-known/jwks.json 返回当前及上一版本的公钥(轮换过渡期两把 key 共存)。/authorize 端点(核心)client_id、redirect_uri(必须精确字符串匹配白名单,禁止前缀匹配)、response_type=code、code_challenge、code_challenge_method=S256。txn_id。state=S2、nonce=N2 重定向到 Azure AD B2C 的 /authorize(带上 txn_id 用于回调时找回上下文,可放在自定义的 state 里签名保护,或直接作为 session cookie 关联)。/callback 端点:
code 向 B2C 的 token endpoint 换取 B2C 的 id_token。iss (精确匹配,不能只检查前缀或域名)、aud(我们,即Broker,在B2C注册的客户端ID)、exp、nonce=N2(必须做,防重放)。azp:存在时校验 Authorized Party; iat:必要时检查签发时间; nbf:存在时检查生效时间;
txn_id 对应的原始上下文。HttpOnly + Secure + SameSite=Lax 的 session cookie,session 内容映射到服务端 store 中的用户身份。authorization code(与 client_id、code_challenge、nonce=N1、用户身份绑定),TTL 60~120 秒。redirect_uri,携带 code 和原始的 state=S1(必须原样带回,否则客户端会因 state 不匹配而报 CSRF 错误)。/token 端点grant_type=authorization_code 分支:
client_id / client_secret(如为机密客户端)。code 是否存在、未过期、未被使用过(用完即删,防重放)。code_verifier 重新计算并比对 code_challenge(PKCE 校验)。iss=我们自己,aud=client_id,sub=稳定用户标识,nonce=N1(取自 authorization code 绑定的原始 nonce)。不建议直接使用 email 作为 sub,因为 email 可能改变,或者多租户之间存在相同 email 等问题。可以使用不变标识组合生成,比如 hash(上游issuer + 上游sub)
client_id 对应的 allowed_scopes 生成权限范围。实际应该根据业务需求生成 access_token,最终 scope 是“请求 scope、客户端允许 scope、用户/租户权限”三者经过授权后的结果
client_id、用户、有效期。grant_type=refresh_token 分支:
轮换的操作要保证原子的:通过 Redis Lua、数据库事务来保证
Token Claim 设计:
access_token 通常需要以下字段:
iss
sub
aud
exp
iat
nbf
jti
scope
client_id 或 azp
tenant_id
不要把过多用户资料塞进 access_token;ID Token 给客户端,Access Token 给 API,两者不要混淆使用.
/userinfo、/revoke、/logout(end_session)/userinfo:凭 access_token 返回用户基本信息(标准 OIDC SDK 默认会调用,缺失会导致部分 SDK 报错或降级)。/revoke:主动吊销指定的 refresh_token / access_token。/logout:清除本地登录 session cookie;同时决定是否联动触发 B2C 侧的登出(见下方风险点)。我们的服务器在这个流程里同时参与两段独立的 OAuth 交易:
绝对不能把 S1 直接透传给 B2C,也不能把 B2C 返回的 id_token 未经处理直接转发给客户端。正确做法是:
txn_id(内部关联标识)把 "B2C 那次交互" 和 "客户端原始那次请求(S1/N1/C1)" 关联起来;这是关于 SSO 最容易误解的一点:
我们的登录 session(第 3 步建立的那个 cookie)只跟"用户身份"绑定,不跟 client_id 绑定。而 access_token 才是跟 client_id 绑定的。
也就是说,同一个已登录的浏览器 session,可以针对不同的 client_id 分别走 /authorize → /token,各自换取各自权限范围的 access_token,而不需要重新跳转 B2C、不需要重新输密码。这在以下场景下会被触发:
client_id(如"工作流设计器"和"监控看板"两个网站);下游客户端的 OIDC SDK 校验 id_token 时会核对 iss 是否等于其在 Discovery 文档里读到的 issuer。转发 B2C 的 id_token 会导致 iss 不匹配而校验失败。因此,"替换 id_token" 不是可选项,而是协议正确性的硬性要求。
| 风险点 | 说明 | 建议 |
|---|---|---|
| redirect_uri 校验方式 | 前缀匹配容易被 open redirect 攻击利用 | 必须精确字符串全匹配白名单 |
| state 混用 | 把客户端的 S1 直接传给 B2C,或把 B2C 的 S2 直接回传给客户端 | 严格区分两段交易的 state/nonce,用内部 txn_id 桥接 |
| nonce 重放 | 不校验 nonce 或 nonce 可重复使用 | nonce 必须一次性,校验后立即失效 |
| Authorization code 重放 | code 被使用后未失效 | code 必须一次性使用,用后立即从存储删除 |
| PKCE 缺失或校验不严格 | 仅记录 code_challenge 但 token 端点不做校验 | /token 必须重新计算 code_verifier 并比对 |
| Refresh token 无轮换 | 长期使用同一个 refresh_token,泄露后无法感知 | 每次 refresh 轮换新 token,检测旧 token 重放并吊销整链 |
| 登出不彻底 | 只清了我们自己的 session,但 B2C 侧 session 还在,导致"登出"后重新登录可"免密"通过 B2C | 明确产品策略:是否需要触发 B2C 的 single logout;至少在文档中说明该行为 |
| JWKS 无轮换机制 | 单一签名密钥,轮换时旧 token 全部失效 | 采用 kid + 多把 key 共存的过渡期设计 |
| 多租户权限隔离 | access_token 的 scope 未按 client_id 严格隔离 | 确保 A 客户的 token 无法访问 B 客户的数据/权限范围 |
| Token endpoint 无限流 | 容易被暴力枚举/爆破攻击 | 增加速率限制与异常检测 |
| 错误响应格式不规范 | 不遵循 RFC 6749 的 error / error_description 字段 | 严格按标准格式返回错误,避免第三方 SDK 解析失败 |
| client_secret 误用于纯前端 SPA | 若下游客户端是纯前端(浏览器直接发起 /token 请求,没有自己的后端中转),client_secret 会被打包进 JS 下发到浏览器,任何人都能看到,起不到保密作用,属于 public client;只有"有自己后端、secret 存于服务端"的 confidential client 才应该要求并校验 secret | 在 client 注册表增加 client_type: confidential | public 字段;confidential 类型要求并校验 client_secret_hash,public 类型不要求 secret 但强制要求 PKCE;务必确认"跳过 secret 校验"的逻辑只对显式标记为 public 的 client 生效,不能变成"没配置 secret 的 client 一律放行"的漏洞 |
前面的流程能把主链路跑通,但 Identity Broker 一旦进入生产环境,它就不只是一个"登录中转站",而是整个系统的认证边界。下面这些能力建议作为上线前的硬性检查项,否则主流程即使能跑通,也可能在边界场景里留下安全缺口。
所有认证相关端点必须只允许 HTTPS,包括 /.well-known/openid-configuration、/authorize、/token、/userinfo、/revoke、/logout 以及 B2C 回调地址。redirect_uri 必须使用注册表里的精确字符串匹配,不要做前缀匹配、通配符匹配或"同域名即可"这类宽松判断。
授权码可以出现在回调 URL 里,但 access_token、refresh_token、id_token、code_verifier、cookie 等敏感值不应该进入 URL、日志、异常消息或前端埋点。生产日志里至少要对 code、state、nonce、Authorization header、Cookie、token 响应体做脱敏。
state 不只是"把用户带回原页面"的字段,它也是防 CSRF 的关键。Broker 应该为下游客户端请求和上游 B2C 请求分别生成独立的 state / nonce,并把它们绑定到一次性的服务端transaction记录中。transaction记录应有较短 TTL,校验成功后立即删除。
如果下游客户端是 public client,必须强制 PKCE。对 confidential client,也建议支持并优先使用 PKCE。/token 端点不能只检查 code 是否存在,还必须重新计算 code_verifier 与原始 code_challenge 是否匹配。
Broker 不能根据传入 token 里的 iss 动态决定去哪里拉 JWKS,也不能接受非注册上游返回的 issuer。每个租户或上游身份源都应该有明确的 allowlist 配置,包括 issuer、authorization endpoint、token endpoint、JWKS URI、client_id 等。
校验上游 id_token 时,至少要严格验证:
iss 必须等于预期 issuer;aud 必须包含 Broker 在该上游注册的 client_id;alg 必须是允许的签名算法,不能接受 none;kid 必须能在预期上游的 JWKS 中找到;exp、nbf、iat 要在合理时间窗口内;Broker 自己的登录 session cookie 应设置 HttpOnly、Secure、SameSite=Lax 或更严格策略。完成登录后要重新生成 session id,避免攻击者在登录前固定一个 session id,再诱导用户使用这个 session 登录。
登录 session 只应该绑定用户身份和必要的上下文,不要把下游 client_id、scope 或权限结果直接塞进登录 session。不同 client 的授权结果应由各自的 authorization code / access_token 表达,这样才能避免 SSO 和授权边界混在一起。
client 注册的表里应明确区分 public 和 confidential。confidential client 必须校验 client_secret 或其他已注册认证方式;public client 不校验 secret,但必须强制 PKCE。不要把"没有配置 secret"自动解释成 public client,否则容易把配置错误变成认证绕过。
错误响应应遵循 OAuth2 标准字段,例如 error 和 error_description,但不要在错误信息里泄露过多细节。比如"client_id 不存在"、"secret 错误"、"redirect_uri 不匹配"可以在服务端审计日志里记录清楚,对外响应则保持足够泛化,避免被用来枚举 client 或探测配置。
refresh_token 不应明文存储。可以存储哈希值或加密后的值,并记录 token family、上一版本 token、过期时间、吊销状态和重放检测结果。每次 refresh 都应轮换 refresh_token;一旦检测到旧 refresh_token 被重放,应吊销同一 token family 下的后续 token。
access_token 建议短有效期。若使用 JWT access_token,需要设计吊销策略,例如缩短过期时间、配合版本号/会话版本检查,或在高风险 API 上做 introspection。若使用 opaque token,则必须保证 introspection / token lookup 的性能、缓存和吊销一致性。
Broker 自己签发 token 的私钥应放在 Key Vault、HSM 或等价的密钥管理系统中,不要放在代码仓库、镜像或普通配置文件里。JWKS 只暴露公钥,并通过 kid 支持密钥轮换。
密钥轮换时应至少保持"新旧公钥并存"一段时间:新 token 用新 key 签发,旧 token 在最大有效期内仍能被旧公钥验证。等所有旧 token 过期后,再从 JWKS 中移除旧公钥。
上游 B2C 的 metadata 和 JWKS 可以缓存,但要设置合理 TTL。遇到未知 kid 时可以触发一次受控刷新,但不能根据未信任 token 里的任意 URL 去拉取配置。
/authorize、/token、/userinfo、/revoke、/logout 和回调端点都应该有限流。限流维度可以包括 IP、client_id、用户账号、租户、失败次数和设备指纹等。尤其是 /token 端点,它直接处理 code、refresh_token 和 client 认证,不应允许无限尝试。
审计日志应覆盖 client 注册变更、redirect_uri 变更、密钥轮换、异常登录、refresh_token 重放、吊销操作、上游 issuer 变更等事件。生产环境还应配置告警,例如某个 client 的失败率突然升高、未知 redirect_uri 请求变多、旧 refresh_token 重放、JWKS 频繁刷新失败等。
本地 /logout 清除 Broker session,不一定等于清除了 B2C 的登录状态。产品上必须明确这个语义:用户点击退出后,是只退出当前 Broker,还是也要触发上游 B2C 的 single logout。
如果需要完整退出,应实现上游支持的 end session / RP-initiated logout 流程,并处理回跳、失败和多客户端会话清理。即使不做上游登出,也要在产品文案和测试用例里说明:清除 Broker session 后再次登录,仍可能因为 B2C session 存在而免密完成认证。
| 检查项 | 最低要求 |
|---|---|
| HTTPS | 所有认证端点和回调地址强制 HTTPS |
| redirect_uri | 精确匹配注册表,不允许通配符或前缀匹配 |
| state / nonce | 上下游独立生成、一次性、短 TTL、校验后删除 |
| PKCE | public client 强制启用,token 端点严格校验 |
| client 类型 | 明确区分 public / confidential,不能用"缺 secret"隐式放行 |
| token 存储 | refresh_token 不明文存储,支持轮换、重放检测和吊销 |
| 日志 | 敏感字段脱敏,不记录 token、secret、cookie、code_verifier |
| 签名密钥 | 私钥进密钥管理系统,通过 kid 支持平滑轮换 |
| issuer 校验 | 上游 issuer / JWKS / endpoint 必须来自 allowlist |
| 限流与审计 | 认证关键端点有限流,安全事件可追踪、可告警 |
/.well-known/openid-configuration,核对每个字段(尤其 jwks_uri、end_session_endpoint)都对应真实实现,而非占位符。client_id=A1 成功后,不清 cookie 直接访问 /authorize?client_id=A2&...,验证是否:
redirect_uri 并带上 code。State 篡改:手动修改客户端请求中的 state 参数,验证是否被正确拒绝或检测到不一致。具体步骤:
Code 重放:用同一个 code + code_verifier 换两次 token,验证第二次是否被拒绝(code 一次性)。
PKCE 失配:用错误的 code_verifier 换 token,验证是否被拒绝。
Nonce 重放:在 B2C 回调环节重复提交同一个 nonce,验证是否被识别为重放。(Broker 端测试)
Redirect_uri 篡改:提交一个非白名单内、但与白名单 URI 有相同前缀的 redirect_uri,验证是否被拒绝(防 open redirect)。
Refresh Token 轮换检测:正常 refresh 一次后,再用已经失效的旧 refresh_token 重放,验证是否:
client_id=A1 签发的 access_token,尝试访问需要 client_id=B1 权限范围的 API,验证是否被拒绝。/logout,验证:
/authorize(同一 client_id 或不同 client_id)是否需要重新完成 B2C 认证,还是"免密"直接通过(需依据产品策略判断是否符合预期)。error + error_description)。暂无评论,快来抢沙发吧!