上一篇:IAM 全景:为什么这是高价值赛道 | 下一篇:OAuth 2.1 与 PKCE:现代授权主路径
一家做 B2B 数据分析 SaaS 的公司,ARR 800 万美元,签下一家《财富》500 强客户的订单,年合同额 120 万美元。合同走到最后一步,对方 IT 安全团队发来一封邮件:「我们所有 SaaS 工具必须通过 Okta SSO 登录,不允许单独的用户名密码,不允许本地账号,不允许 LDAP 直连。请在两周内提交你们的 OIDC Metadata URL、支持的 Signing 算法、Claims Mapping 文档,以及 SCIM 2.0 开通接口规格。通过安全评审后合同才能生效。」
工程团队翻出产品代码,登录功能是两年前一个实习生写的:前端
POST /api/login,后端查 MySQL,bcrypt
比对密码,返回
JWT。能跑,从来没出过事,但离客户要的「接入我们的
Okta」还有十万八千里。更让人头疼的是,销售同时还在谈另外三家客户,一家用
Microsoft Entra ID(原 Azure AD),一家用
OneLogin,一家内部自研了一套基于 Keycloak
的身份系统。如果每家都单独做对接,研发团队接下来半年就别干别的了。
这不是技术选型问题,而是商业准入问题。OpenID Connect(OIDC)之所以在过去十年成为企业 SSO 的事实标准,核心原因就是它把「每家 IdP 一种对接方式」变成了「一次对接,所有合规 IdP 都能接入」。这篇文章围绕一个核心问题展开:OIDC 在 OAuth 2.0 之上到底加了什么?企业集成里有哪些协议细节是写 demo 时永远不会碰到、上生产后一定会踩的?
一、什么场景下 SSO 是硬需求
SSO 不是「做了更好」的功能,它是很多商业交易的前置条件。先把触发 SSO 需求的几类典型场景摊开,后面讨论协议时才不会停留在抽象层面。
1.1 B2B 大客户的安全合规要求
大型企业客户采购任何 SaaS 工具时,都会走一个叫做「Vendor Security Review」的流程。这个流程里几乎一定会出现三个问题:
- 是否支持 SSO(具体是 OIDC 还是 SAML 2.0)?
- 是否支持 SCIM 2.0 自动开通/离职?
- 是否能导出审计日志给我们的 SIEM(安全信息与事件管理,Security Information and Event Management)系统?
这三个问题里,SSO 是最底层的那一个。原因很简单:企业的员工生命周期管理(入职、转岗、离职)都在他们自己的身份源里,HR 系统同步到 Okta/Entra ID/Google Workspace,再下发到所有工具。如果某个 SaaS 工具不接入这套流程,意味着员工离职时 IT 团队要手工去每个工具禁用账号——这在 10 个工具时还能忍受,在 200 个工具时就是一个必然出事故的操作。所以大企业的安全策略通常就是一句话:没有 SSO,不准采购。
1.2 集团多产品线的统一登录
一家集团公司有五条产品线:电商、支付、物流、金融、SaaS。各产品线历史上都有自己的用户体系,同一个企业主在五个产品里有五份账号,五份密码,五份权限。任意一次产品升级或者组织架构调整都要同步改五份。
统一账号体系不是「把五张用户表合成一张」这么简单——数据迁移只是表面。真正的价值在于:新接入的第六条产品线不需要再自建账号系统,直接接入集团 IdP 就行;权限变更可以集中下发;审计可以集中查询;MFA(多因素认证,Multi-Factor Authentication)策略可以统一执行。OIDC 是这类场景的标准答案,因为它定义了清晰的 RP(Relying Party,依赖方,也就是接入方)和 OP(OpenID Provider,身份提供方)边界。
1.3 SaaS 平台接入第三方 IdP
和场景 1.1 对称的另一面:作为 SaaS 平台方,你的租户(Tenant)来自不同公司,每个公司有自己的 IdP。一个理想的 B2B SaaS 产品要做到:租户管理员在控制台填一个 Issuer URL,系统自动完成 Discovery、获取 JWKS、配置 Claims Mapping,该租户的员工就可以用公司邮箱直接 SSO 登录。这种「自助式 SSO 配置」是 B2B SaaS 的高价值功能,直接决定了你能不能卖给「企业版」以上的客户。
1.4 合规驱动的 Single Source of Truth
SOC 2、ISO 27001、等保 2.0 这类合规体系都要求组织有一个「访问控制的唯一事实源」(single source of truth for access)。具体落到审计里,审计员会问:「请展示某员工在过去 90 天里访问了哪些系统」。如果每个系统的登录都独立进行,这个问题没法在可接受的时间内回答;如果所有登录都经过一个 OIDC OP,那么 OP 的日志就是唯一答案。
合规不是可选项。哪怕你不在意 SSO 本身,只要你的客户需要对他们的监管机构解释「我们怎么管住访问」,SSO 就成为了商业合同里的硬性条款。
二、OAuth 2.0 回顾
要讲清楚 OIDC 加了什么,必须先约定 OAuth 2.0 是什么。详细内容见 OAuth 2.0 基础 和 认证架构基础,这里只做最小必要回顾。
OAuth 2.0(RFC 6749)本质上是一个授权框架,解决的是「第三方应用在用户授权下访问用户资源」的问题。最典型的例子是「用 GitHub 账号登录 Travis CI 并允许 Travis CI 读取仓库列表」。OAuth 2.0 定义了四个角色:
- 资源所有者(Resource Owner):通常就是用户本人
- 资源服务器(Resource Server):持有受保护资源的服务,例如 GitHub API
- 客户端(Client):第三方应用,例如 Travis CI
- 授权服务器(Authorization Server):颁发 Access Token 的服务,例如 GitHub 的 OAuth 端点
OAuth 2.0 的核心产出是 Access Token,一个「代表用户授权的凭证」,客户端拿着它去调用资源服务器。这里有一个极其重要的事实:OAuth 2.0 从不回答「用户是谁」这个问题。 Access Token 不是用户身份,它是一个「可以调用某些 API」的许可证。这也是 2013 年前大量把 OAuth 2.0 当登录协议用的系统都有安全漏洞的原因——它们在用一个授权协议做认证的事。
OIDC 就是为了补上这个缺口而设计的。
三、OIDC 在 OAuth 2.0 上加了什么
OpenID Connect Core 1.0 由 OpenID Foundation 于 2014 年发布,它是 OAuth 2.0 的一个身份层(identity layer)。从协议关系上看:OIDC 完全复用 OAuth 2.0 的授权端点、令牌端点、授权码流程,只在关键位置增加了几个新东西。
3.1 id_token:结构与 Claims
OIDC 最核心的新增品是 id_token。它是一个
JWT(JSON Web Token),由 OP 签发、以 RS256/ES256/EdDSA
等非对称算法签名,专门用来告诉
RP「这个登录的人是谁」。id_token 和
access_token 的本质区别可以用一句话概括:
access_token是给资源服务器看的,代表授权,格式协议没有强约束(可以是 JWT,也可以是不透明字符串)。id_token是给 RP 看的,代表身份,必须是 JWT,必须可被 RP 本地验签。
id_token 的标准 Claims(OIDC Core 1.0
§2):
{
"iss": "https://login.example-idp.com",
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"aud": "client-id-of-your-rp",
"exp": 1767225600,
"iat": 1767222000,
"auth_time": 1767221800,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "otp"],
"azp": "client-id-of-your-rp",
"at_hash": "77QmUPtjPfzWtF2AnpK9RQ",
"c_hash": "LDktKdoQak3Pk0cnXxCltA",
"email": "alice@example-customer.com",
"email_verified": true,
"name": "Alice Chen"
}逐项说明每个字段为什么存在:
iss(Issuer):签发者标识,必须是一个 HTTPS URL,RP 必须把它和自己配置的 Issuer 精确字符串比对(不是 URL 规范化比对,是逐字节比对)。sub(Subject):用户在该 OP 下的稳定标识。不是 email、不是用户名,不能被用户修改。RP 应该以(iss, sub)组合作为「同一个用户」的主键。aud(Audience):这个 token 的目标受众,值等于 RP 的client_id。RP 必须校验aud包含自己的client_id,否则同一个 OP 下的 A 客户端可以拿到的 id_token 会被 B 客户端误接受。exp(Expiration)、iat(Issued At):过期时间、签发时间,UNIX 时间戳,必须按秒精度校验。auth_time:用户实际完成认证的时间。和iat可以不一样——用户 9:00 登录 OP,RP 9:30 才拿到 id_token,auth_time=9:00、iat=9:30。nonce:RP 在发起/authorize请求时生成的随机字符串,OP 必须原样带回,RP 必须校验 nonce 和本地存储(Session、Cookie)里的 nonce 一致。防重放攻击。acr(Authentication Context Class Reference)、amr(Authentication Methods References):认证强度信息。例如amr=["pwd","otp"]表示用户走了密码加 TOTP,amr=["pwd"]表示只输了密码。高安全场景下 RP 可以拒绝amr不含otp的登录。azp(Authorized Party):当 id_token 的 aud 是多个时,azp 标明主接收方。at_hash、c_hash:Access Token 哈希和 Authorization Code 哈希。在 Implicit/Hybrid Flow 下用来把 id_token 和另外那段单独传输的 Token/Code 绑定在一起,防止被替换。
3.2 UserInfo endpoint
有时候 id_token 里的 Claims 不够,RP 需要拿到更多用户信息(头像、手机号、部门等)。OIDC 定义了一个 UserInfo 端点:
GET /userinfo HTTP/1.1
Host: login.example-idp.com
Authorization: Bearer <access_token>
HTTP/1.1 200 OK
Content-Type: application/json
{
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "Alice Chen",
"email": "alice@example-customer.com",
"email_verified": true,
"picture": "https://cdn.example-idp.com/u/a1b2c3d4.png",
"phone_number": "+86-13800138000",
"department": "Data Platform"
}
这里的 access_token 是 OIDC
流程中同时签发的那一份。注意一个重要约束:UserInfo 响应里的
sub 必须和 id_token 里的
sub
一致,否则这次响应有可能是对另一个用户的信息,RP
必须拒绝。这一条工程化实现里经常被漏掉。
3.3 Discovery 文档
手工给 RP 配置 authorize/token/userinfo/jwks 每个端点的 URL 是一件很痛苦的事。OIDC Discovery 1.0 规范允许 RP 只配置一个 Issuer URL,剩下的端点都通过一个固定的 metadata 文档发现:
GET /.well-known/openid-configuration HTTP/1.1
Host: login.example-idp.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"issuer": "https://login.example-idp.com",
"authorization_endpoint": "https://login.example-idp.com/oauth2/authorize",
"token_endpoint": "https://login.example-idp.com/oauth2/token",
"userinfo_endpoint": "https://login.example-idp.com/oauth2/userinfo",
"jwks_uri": "https://login.example-idp.com/oauth2/jwks",
"registration_endpoint": "https://login.example-idp.com/oauth2/register",
"revocation_endpoint": "https://login.example-idp.com/oauth2/revoke",
"introspection_endpoint": "https://login.example-idp.com/oauth2/introspect",
"end_session_endpoint": "https://login.example-idp.com/oauth2/logout",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code", "id_token", "code id_token"],
"response_modes_supported": ["query", "fragment", "form_post"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "EdDSA"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"private_key_jwt",
"tls_client_auth"
],
"code_challenge_methods_supported": ["S256"],
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true,
"frontchannel_logout_supported": true
}
工程上值得单独拎出来的字段:
id_token_signing_alg_values_supported:OP 用哪些算法给 id_token 签名。RP 必须把「期望的算法」固定下来,不能信任 token 里 header 的alg——这也是 JWT 的经典陷阱之一。token_endpoint_auth_methods_supported:OP 支持的客户端认证方式。private_key_jwt和tls_client_auth是比client_secret_*更强的方案,金融级客户会直接要求使用。subject_types_supported:pairwise意味着同一个用户在不同 RP 看到的sub不同,防止多个 RP 交叉关联用户——高敏感场景下非常重要。backchannel_logout_supported:OP 是否支持后向通道登出(见第六节)。
3.4 JWKS endpoint
id_token 既然是 JWT,RP 必须拿得到公钥才能验签。OIDC 通过
jwks_uri 发布公钥:
GET /oauth2/jwks HTTP/1.1
Host: login.example-idp.com
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=3600
{
"keys": [
{
"kty": "RSA",
"kid": "2026-01-signing-key",
"use": "sig",
"alg": "RS256",
"n": "vGc9N6n...Q",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "2026-04-signing-key",
"use": "sig",
"alg": "RS256",
"n": "w7kPx...K",
"e": "AQAB"
}
]
}
关键工程约束:
- RP 必须按 id_token header 里的
kid(Key ID)匹配,不能只取第一把 Key。OP 在密钥轮换(Key Rotation)期间会同时发布新旧 Key,此时 JWKS 里有两把 Key,旧 token 用旧 kid、新 token 用新 kid。 - JWKS 的 HTTP 响应会带
Cache-Control,RP 可以按这个值做本地缓存。但一旦遇到未知kid,应该立刻去拉一次 JWKS 而不是等缓存过期(否则轮换窗口期内所有用户都登录失败)。
3.5 Session Management 与 Logout
OAuth 2.0 没有 Logout 概念,因为它本来就不管登录。OIDC 补了三种 Logout 方式,分别解决不同的工程约束:
- RP-Initiated Logout:RP 引导浏览器 GET 到 OP 的
end_session_endpoint,带上id_token_hint和post_logout_redirect_uri,OP 登出后重定向回 RP。 - Front-Channel Logout:OP 在自己的登出页面用
<iframe>依次加载每个已登录 RP 的frontchannel_logout_uri,RP 在这些 iframe 里清掉自己的 Session。 - Back-Channel Logout:OP 通过服务器到服务器的 HTTPS POST
把一个 Logout Token 送到 RP 的
backchannel_logout_uri,RP 收到后清 Session。
企业场景里 Back-Channel 是最可靠的,因为它不依赖用户浏览器开着;Front-Channel 依赖 iframe 跨域能力,在 Safari ITP、第三方 Cookie 禁用的大趋势下基本失效;RP-Initiated 只能清掉用户主动点「退出」的那一个 RP,其他 RP 下次请求时才会被动发现登录态丢失。
3.6 Dynamic Client Registration(RFC 7591)
大部分企业场景里 Client 是静态注册的——管理员在 OP
控制台填表,拿到一个
client_id/client_secret 给到 RP
开发者。但在「自助式 SSO 配置」、「SaaS 平台动态发现租户
IdP」等场景下,这种流程太慢。RFC 7591 定义的 Dynamic Client
Registration 允许 RP 通过 HTTP POST 向 OP 的
registration_endpoint
发请求,自助注册自己:
POST /oauth2/register HTTP/1.1
Host: login.example-idp.com
Content-Type: application/json
Authorization: Bearer <initial_access_token>
{
"client_name": "Acme Analytics (Production)",
"redirect_uris": ["https://app.acme.com/oidc/callback"],
"post_logout_redirect_uris": ["https://app.acme.com/logout"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://app.acme.com/.well-known/jwks.json",
"scope": "openid profile email",
"contacts": ["security@acme.com"],
"software_id": "acme-analytics",
"software_version": "4.2.1"
}
OP 返回:
{
"client_id": "s6BhdRkqt3",
"client_id_issued_at": 1767222000,
"registration_access_token": "reg-xxx...",
"registration_client_uri": "https://login.example-idp.com/oauth2/register/s6BhdRkqt3",
"redirect_uris": ["https://app.acme.com/oidc/callback"],
"token_endpoint_auth_method": "private_key_jwt"
}RP 拿到 client_id 和
registration_access_token,后续可以用后者继续管理/更新这个
Client 注册。FAPI(Financial-grade API)和 Open Banking
场景里 Dynamic Registration 加
software_statement(由一个可信方签名的
JWT,背书 Client 身份)是标配。
四、授权码流程逐步时序
OIDC 推荐的核心流程是 Authorization Code Flow with PKCE。下面把每一步对应的 HTTP 请求和响应都拆开,对应 SVG 图中的 13 个步骤。
4.1 第 1 – 2 步:RP 发起登录
用户浏览器访问 RP 的 /login,RP 生成
state、nonce、code_verifier
三个随机值,分别存在用户的临时 Session(Signed Cookie
也可)里,然后重定向到 OP 的 /authorize。
HTTP/1.1 302 Found
Location: https://login.example-idp.com/oauth2/authorize?
response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fapp.acme.com%2Foidc%2Fcallback
&scope=openid%20profile%20email%20offline_access
&state=xyzABC123
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&prompt=select_account
参数的工程含义:
state:一个不可预测的随机字符串,RP 把它同时写进 Cookie 和 URL。回调时校验两处相等,防止攻击者伪造回调 URL(CSRF)。nonce:同样是随机字符串,RP 存在 Cookie 里,稍后校验 id_token 的 nonce Claim 和这个值一致,防重放。code_challenge:code_verifier的 SHA-256 base64url 编码(PKCE,详见第 3 篇)。scope=openid:必须带openid才是 OIDC 流程,否则是纯 OAuth 2.0。prompt=select_account:提示 OP 显示账号选择页,即使用户已经登录了某个账号。offline_access:申请 Refresh Token。
4.2 第 3 – 6 步:OP 完成认证
OP 收到 /authorize
请求后执行的内部流程:
- 校验
client_id存在、redirect_uri在注册时的白名单内、scope合法。 - 检查用户的 OP Session Cookie,未登录则展示登录页(密码、passkey、SAML 联邦、AD 联邦等)。
- 要求用户同意
scope中列出的权限(首次登录时,之后可记住)。 - 执行 MFA 策略(如果策略要求)。
- 生成一个一次性、短期的 Authorization
Code(通常寿命 30 秒到 2 分钟),把
nonce、code_challenge、redirect_uri、user_id等绑定到这个 code 上存入服务端。 - 302 重定向回 RP:
HTTP/1.1 302 Found
Location: https://app.acme.com/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyzABC123
4.3 第 7 – 9 步:RP 用 code 换 token
RP 的 /oidc/callback 被浏览器访问:
func OIDCCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
sess, err := loadSession(r)
if err != nil {
http.Error(w, "session missing", 400)
return
}
if subtle.ConstantTimeCompare([]byte(state), []byte(sess.OIDCState)) != 1 {
http.Error(w, "state mismatch", 400)
return
}
// 用 code + code_verifier 换 token
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", "https://app.acme.com/oidc/callback")
form.Set("client_id", cfg.ClientID)
form.Set("code_verifier", sess.CodeVerifier)
req, _ := http.NewRequestWithContext(ctx, "POST", cfg.TokenEndpoint,
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(cfg.ClientID, cfg.ClientSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode != 200 {
http.Error(w, "token exchange failed", 500)
return
}
defer resp.Body.Close()
var tok TokenResponse
_ = json.NewDecoder(resp.Body).Decode(&tok)
// 验 id_token
claims, err := verifyIDToken(ctx, tok.IDToken, sess.Nonce)
if err != nil {
http.Error(w, "invalid id_token: "+err.Error(), 401)
return
}
// 落库 / 建立应用 Session
finalizeLogin(w, r, claims, &tok)
}OP 返回:
{
"access_token": "eyJraWQiOi...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_9f8e7d6c5b4a",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDQtc2lnbmluZy1rZXkifQ...",
"scope": "openid profile email offline_access"
}4.4 第 10 步:验 id_token
这一步是最容易出问题的地方。一个完整的本地验签,至少要做这些事:
func verifyIDToken(ctx context.Context, raw string, expectedNonce string) (*IDTokenClaims, error) {
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return nil, errors.New("not a JWT")
}
var hdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
_ = json.Unmarshal(hb, &hdr)
// 1. 算法白名单,绝不信任 header.alg
if hdr.Alg != "RS256" && hdr.Alg != "ES256" {
return nil, fmt.Errorf("alg %s not allowed", hdr.Alg)
}
// 2. 按 kid 取公钥,未知 kid 要立即刷 JWKS
key, err := jwksCache.Get(ctx, hdr.Kid)
if err != nil {
return nil, fmt.Errorf("key %s not found: %w", hdr.Kid, err)
}
// 3. 验签
signingInput := parts[0] + "." + parts[1]
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
if err := verifySignature(hdr.Alg, key, signingInput, sig); err != nil {
return nil, err
}
// 4. 解析 claims
var c IDTokenClaims
pb, _ := base64.RawURLEncoding.DecodeString(parts[1])
if err := json.Unmarshal(pb, &c); err != nil {
return nil, err
}
now := time.Now().Unix()
const skew = 60 // 允许 60 秒时钟漂移
// 5. iss 精确匹配
if c.Iss != cfg.Issuer {
return nil, fmt.Errorf("iss mismatch: %s", c.Iss)
}
// 6. aud 必须包含自己
if !audienceContains(c.Aud, cfg.ClientID) {
return nil, errors.New("aud mismatch")
}
// 7. azp 校验(多 audience 时必要)
if len(c.Aud) > 1 && c.Azp != cfg.ClientID {
return nil, errors.New("azp mismatch")
}
// 8. 时间窗
if c.Exp+skew < now {
return nil, errors.New("id_token expired")
}
if c.Iat-skew > now {
return nil, errors.New("id_token issued in the future")
}
// 9. nonce 必须严格一致
if subtle.ConstantTimeCompare([]byte(c.Nonce), []byte(expectedNonce)) != 1 {
return nil, errors.New("nonce mismatch")
}
// 10. at_hash 可选但推荐
if c.AtHash != "" {
if !verifyAtHash(c.AtHash, hdr.Alg, accessTokenFromCtx(ctx)) {
return nil, errors.New("at_hash mismatch")
}
}
return &c, nil
}这十步里每一步都对应一个真实历史漏洞。第 1 步对应
alg=none 攻击;第 6 步对应多年以前某个大型 IdP
的 client 越权事故;第 9 步对应 id_token 重放;第 10 步对应
Hybrid Flow 下 token 替换。没有 OIDC
库可以跳过,自己写要一条条过。
4.5 第 11 – 13 步:UserInfo 和调用资源服务器
拿到 access_token 后,RP 可以调用 UserInfo
获取更多用户信息,也可以把 access_token
透传给下游资源服务器。资源服务器既可以做本地 JWT
验签(前提是 access_token 是 JWT),也可以调用 OP 的
/introspect 端点(RFC
7662)做远程校验。金融级场景一般倾向 introspect,因为可以在
OP 侧做即时吊销。
五、state、nonce 与随机性的工程细节
state 和 nonce 在协议文本里各自一句话就写完了,但工程实现里至少有三件事值得单独写清楚。
5.1 state 的存储载体
把 state 放在 URL 里是协议要求,但它的「另一份副本」放在哪里是 RP 自己决定的。三种选择:
- Server-side Session:RP 用 Signed Session Cookie 指向一张服务端表,state 写在表里。优点:安全;缺点:回调机器必须能访问 Session 存储,负载均衡器不能自由调度。
- 签名的短 Cookie:RP 把 state 直接放在一个独立的、HttpOnly、Secure、SameSite=Lax 的 Cookie 里。优点:无状态,任何机器都能处理回调;缺点:必须签名防篡改。
- 双重提交:既写 Cookie 也写后端存储,两边都要匹配。适合高安全场景。
SameSite=Lax 是必选项,不能设 None——OIDC 回调是一个浏览器重定向 GET,Lax 刚好允许顶级导航时带上 Cookie,而 None 会把这个 Cookie 暴露给所有跨站请求。
5.2 nonce 生成
nonce
的强度要求是不可预测,不是「仅仅唯一」。用
crypto/rand 产生 128 bit 随机数然后 base64url
编码是标准做法:
func newNonce() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}不要用 time.Now().UnixNano()、不要用
UUIDv1、不要用业务递增 ID。
5.3 回调一次性
一个登录流程对应一组
(state, nonce, code_verifier),必须严格一次性。回调处理成功后立即从
Session 里删除,防止攻击者拿到用户浏览器的 Cookie
后反复走同一次回调。
六、三种登出方式的工程差异
RP-Initiated Logout:
GET /oauth2/logout?
id_token_hint=eyJhbGci...
&post_logout_redirect_uri=https%3A%2F%2Fapp.acme.com%2Flogout-done
&state=logout-xyz
HTTP/1.1
Host: login.example-idp.com
id_token_hint 是用户当前会话对应的
id_token,OP
以此定位用户会话;post_logout_redirect_uri
必须在注册时登记。这种方式只清 OP 的会话,下游其他 RP
的会话靠 Front-Channel 或 Back-Channel 通知。
Front-Channel Logout:OP 在自己的登出响应里渲染一组 iframe:
<iframe src="https://app-a.example.com/fc-logout?iss=https%3A%2F%2Flogin.example-idp.com&sid=abc123"></iframe>
<iframe src="https://app-b.example.com/fc-logout?iss=https%3A%2F%2Flogin.example-idp.com&sid=abc123"></iframe>RP 的 /fc-logout 在被加载时清掉自己的
Session。Safari 的 ITP、Chrome 的 Third-Party Cookie
废弃使得这种方式在现代浏览器里逐渐失效,因为 iframe 里的 RP
Cookie 会被当成第三方 Cookie 拦掉。
Back-Channel Logout(OIDC Back-Channel Logout 1.0):OP 直接从服务端 POST 到 RP:
POST /oidc/backchannel-logout HTTP/1.1
Host: app.acme.com
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6...
logout_token 是一个 JWT,Claims 形如:
{
"iss": "https://login.example-idp.com",
"aud": "s6BhdRkqt3",
"iat": 1767225600,
"jti": "logout-d8f3",
"events": { "http://schemas.openid.net/event/backchannel-logout": {} },
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sid": "abc123"
}RP 验完签后按 sid 或 sub
主动清本地会话。这种方式不依赖浏览器,是企业 SSO
的首选,但要求 RP
有公网可达的后端端点,部分内网系统做不到。
七、工程坑点
前面讲协议,这一节集中讲生产事故。每一条都对应真实出现过的问题。
7.1 把 access_token 当 id_token 用
现象:RP 开发者第一次接 OIDC
时经常做这种事——拿到 token 响应,直接把
access_token 当 JWT 解,从里面读 email
做登录。
问题:access_token 的格式在 OIDC
规范里没有强制。它可以是不透明字符串,可以是一个
JWE(加密 JWT),可以是 JWT 但不是给 RP
解析的。更严重的是,access_token 的 aud
指向的是资源服务器,不是 RP,哪怕它恰好是
JWT 也不该被 RP 信任。
正确做法:身份信息一律从 id_token 或 UserInfo 取,access_token 只当作「调用资源服务器的凭证」。
7.2 audience 验证缺失
现象:RP 的 id_token 验证代码里没有
aud == client_id 这一步。
问题:同一个 OP 下有多个 RP,攻击者用自己注册的恶意 RP 引诱用户登录,拿到 id_token 后,把它提交给目标 RP 的一个接口(如果该接口接受「裸 id_token」做登录)。因为签名是有效的、签发者是对的,只缺 aud 校验,目标 RP 就把用户登进去了。
正确做法:无条件地做 aud
校验,多 aud 时还要校验 azp。
7.3 signing key rotation 不当
现象 1:RP 在初始化时拉一次 JWKS 存在全局变量里,永不刷新。OP 在一个月后做密钥轮换,所有 id_token 都因为 kid 不匹配而校验失败,全站登录挂掉。
现象 2:RP 按 JWKS 响应的
Cache-Control: max-age=86400 缓存,OP
因为私钥泄露紧急轮换密钥,RP 的缓存要等 24 小时才过期,这 24
小时里新 token 全部失效。
正确做法:本地缓存 + 「未知 kid 触发立刻刷新」的双策略。刷新要做并发去重(singleflight),避免 OP 被打爆。
7.4 clock skew
现象:RP 和 OP 都用 NTP
同步,但分布在不同云厂商、不同可用区,实际时钟偏差可能在几百毫秒到几秒之间。当
OP 签发 id_token 时 iat=1767225600,RP
的系统时钟恰好在 1767225599,RP 看到
iat > now,按严格规则拒绝。
正确做法:允许 30 – 120
秒的时钟偏差(leeway),同时在运维侧监控 NTP
偏差。
7.5 nonce 未校验
现象:RP 使用了某个不完整的 OIDC 库,或者自己实现忘了校验 nonce。
问题:攻击者从受害者设备(比如同一个共享终端)的网络日志里拿到一个旧的 id_token,在另一台机器上注入到 Session 里,就能冒充受害者登录。没有 nonce,这种重放攻击无法阻止。
正确做法:nonce 强校验、state 强校验、code_verifier 强校验三位一体。
7.6 redirect_uri 宽松匹配
现象:RP 在 OP 控制台注册时写
https://app.acme.com/(末尾斜杠),OP
又支持「前缀匹配」(少数 OP 真的这么做)。攻击者构造
https://app.acme.com.evil.com/callback 或
https://app.acme.com/../evil,把用户的授权码偷走。
正确做法:OP 侧强制完整 URL 精确匹配;RP 侧注册时使用最小集合;明确拒绝通配符。
7.7 id_token 体积过大
现象:Group、Role、Permission 塞进 id_token 的 Claims,用户在多部门多角色的企业里,id_token 可能达到 8 – 16KB,触发 HTTP Header 大小限制,触发 Cookie 限制,在某些 CDN 后直接被截断。
正确做法:id_token 只放身份标识(sub、email、name),权限信息通过 UserInfo 获取或在应用侧从业务库查询。
八、mTLS Client Authentication
client_secret_basic
这类方案的问题很明显:一串静态字符串,泄露就完蛋,轮换流程繁琐。RFC
8705 定义了 mTLS Client Authentication,有两种模式:
PKI Mutual TLS:RP 和 OP 之间的 TLS 连接使用客户端证书,证书由某个 CA 签发,OP 按证书的 Subject DN 做客户端识别。
Self-Signed Certificate Mutual TLS:RP 生成自签名证书,证书指纹注册到 OP 控制台,OP 按指纹匹配。这种方式不需要 CA,部署更简单。
配置了 mTLS 的 Client 在 Discovery 里会体现为:
{
"token_endpoint_auth_methods_supported": ["tls_client_auth", "self_signed_tls_client_auth"],
"tls_client_certificate_bound_access_tokens": true
}最后一个字段是个大杀器:它表示 OP 会把签发的 access_token
和调用 /token
端点时用的客户端证书绑定,后续资源服务器也要校验客户端证书指纹和
token 里的 cnf.x5t#S256 Claim 一致。结果就是
access_token
即使泄露,攻击者没有那张证书的私钥也用不了——Token Binding
的正统实现。
Open Banking、PSD2、FAPI 场景几乎都要求这套。
九、Dynamic Client Registration 工作流
回到最开头的商业场景:SaaS 平台要支持租户自助接入 SSO。完整工作流:
- 租户管理员在 SaaS 控制台点「接入 Okta SSO」,填入 Issuer
URL(例如
https://acme.okta.com)。 - SaaS 后端拉取
https://acme.okta.com/.well-known/openid-configuration,检查registration_endpoint是否存在。 - 如果存在,SaaS 用 initial access
token(由租户管理员提前在 Okta 里生成)调用
/register,自助注册一个 client,拿到client_id。 - 如果不存在,SaaS 引导租户管理员在 Okta 控制台手工创建
OIDC App、回填
client_id。 - SaaS 后端根据租户邮箱域名做 Home Realm
Discovery:
alice@acme.com域名为acme.com,查到该租户的 Issuer,就走对应 OIDC 流程。 - 登录成功后 SCIM
2.0(下一篇详见)把员工信息同步过来,做本地账号与 OIDC
sub的绑定。
9.1 企业租户接入 checklist
真正面向企业客户开放「自助接入 OIDC」时,最好把下面这张清单产品化到控制台,而不是让售前或工程师拿文档手工核对:
| 检查项 | 要求 | 说明 |
|---|---|---|
| Issuer URL | 必填,必须可访问 | 用于拉取 Discovery 文档 |
| Discovery 文档 | 必须成功拉取并缓存 | 校验
authorization_endpoint、token_endpoint、jwks_uri、issuer
一致 |
| JWKS | 至少有 1 把可用签名 key | 未知 kid 触发刷新,失败时要有明确告警 |
| Client 认证方式 | private_key_jwt /
tls_client_auth 优先 |
没条件时再退到 client_secret_basic |
| Claim 映射 | email、name、groups、department 可配置 | 不同租户 IdP 的字段名经常不同 |
| 本地用户绑定 | 优先 sub,必要时辅以租户内 email
唯一约束 |
不要只靠 email 做跨租户唯一标识 |
| JIT / SCIM | 明确谁负责首登建号、谁负责后续生命周期 | OIDC 管登录,SCIM 管持续同步 |
| Logout | 企业默认支持 Back-Channel | Front-Channel 只能当 best-effort |
| 回滚 | 可按租户关闭新连接并切回本地登录 | 出现 claim 映射错误时必须能止损 |
做完这张清单,OIDC 接入才算从「协议可用」走到了「产品可交付」。
这一整套做下来的业务结果:销售跟客户说「我们支持 OIDC 自助接入,30 分钟内完成配置」,而不是「给我们两周,工程师手工改配置文件」。这就是协议价值转化为商业价值的路径。
十、选型建议
10.1 OP 选型
自建 Keycloak:适合有专职平台团队、需要深度定制(自定义认证流程、和内部 HR 系统深度集成)、对数据驻留有强要求的企业。Keycloak 的 Realm、Client、Flow、Event Listener、SPI 扩展机制工程细节见第 16 篇。
采购 Auth0 / Okta / Entra ID:适合 B2C SaaS(Auth0)、企业内 IT(Okta / Entra)、深度绑定 Microsoft 生态(Entra)。优势是合规认证、MFA、审计、WebAuthn、风险感知都开箱即用,代价是按月活或 SSO 连接数收费。详细对比见第 17 篇。
AWS Cognito / GCP Identity Platform / 阿里云 IDaaS:云上起步快,但深度定制能力有限,跨云迁移成本高。
10.2 RP 侧实现选型
不要自己写 OIDC 客户端。用成熟库:
- Go:
github.com/coreos/go-oidc/v3、github.com/zitadel/oidc - Node.js:
openid-client - Java:
spring-security-oauth2-client、nimbus-jose-jwt - Python:
authlib - .NET:
IdentityModel.OidcClient、Microsoft.AspNetCore.Authentication.OpenIdConnect
选库的三条标准:是否做 id_token 完整验证(包括 at_hash、c_hash)、是否内置 JWKS 缓存与轮换、是否支持 PKCE。满足这三点以外的「自己写一遍加深理解」基本都是 over-engineering。
10.3 协议版本与兼容
- 新系统统一走 OIDC Core 1.0 +
PKCE(
response_type=code)。 - 不要使用 Implicit
Flow(
response_type=id_token token),已被 OAuth 2.1 明确废弃。 - Hybrid
Flow(
response_type=code id_token)只在少数极端场景下有意义(需要在回调页面立刻获得身份、不等 token endpoint),成本收益基本不划算。 - 企业级安全场景叠加 FAPI 2.0 Baseline / Advanced Profile,详见 OpenID Foundation 的 FAPI 工作组文档。
十一、总结
OIDC 的协议表面看只是 OAuth 2.0 加了一个 JWT,但它真正的价值在于把「企业身份接入」标准化为一次性工程投入。接入第一家 Okta 客户可能花两周,但有了正确实现后,接入第二家 Entra、第三家 OneLogin、第四家 Keycloak,基本只剩「租户管理员填一个 Issuer URL」的工作量。
工程上需要牢牢记住几件事:
- id_token 和 access_token 是两种不同的凭证,用途不能混。
- id_token 验证的十步(alg 白名单、kid 匹配、签名、iss、aud、azp、exp、iat、nonce、at_hash)少一步都可能成为漏洞。
- JWKS 必须本地缓存 + 未知 kid 触发刷新;Logout 优先走 Back-Channel;redirect_uri 精确匹配。
- 企业级场景优先采用
private_key_jwt或tls_client_auth做客户端认证。 - Discovery 文档不是一次性读取的静态文件,它是你和 OP 之间的协议契约。
认证架构的演进方向不是「淘汰」,而是「分层」。OIDC 不会替换掉 Session(OP 内部还是 Session 管登录态),也不会替换掉 JWT(id_token 本身就是 JWT),它在更高的抽象层上解决「跨组织身份互通」的问题。后续几篇会在这个基础上继续往下挖:第 3 篇讲 OAuth 2.1 和 PKCE 的协议新约束,第 4 篇补齐 SAML 的企业现实,第 5 篇讲 SCIM 把账号生命周期自动化起来,第 6 篇把 JWT/JWS/JWE/JWKS 一次讲透。
十二、参考资料
- Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., Mortimore, C.(2014). OpenID Connect Core 1.0. OpenID Foundation.
- Sakimura, N., Bradley, J., Jones, M.(2014). OpenID Connect Discovery 1.0. OpenID Foundation.
- Sakimura, N., Bradley, J., Jones, M.(2014). OpenID Connect Dynamic Client Registration 1.0. OpenID Foundation.
- Jones, M., Bradley, J., Sakimura, N.(2015). RFC 7519: JSON Web Token (JWT). IETF.
- Hardt, D.(2012). RFC 6749: The OAuth 2.0 Authorization Framework. IETF.
- Richer, J., Jones, M., Bradley, J., Machulak, M., Hunt, P.(2015). RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol. IETF.
- Campbell, B., Bradley, J., Sakimura, N., Lodderstedt, T.(2020). RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens. IETF.
- Richer, J.(2015). RFC 7662: OAuth 2.0 Token Introspection. IETF.
- Jones, M., Bradley, J.(2022). OpenID Connect Back-Channel Logout 1.0. OpenID Foundation.
- Jones, M., Bradley, J.(2022). OpenID Connect Front-Channel Logout 1.0. OpenID Foundation.
- Lodderstedt, T., Bradley, J., Labunets, A., Fett, D.(2020). RFC 6819 / RFC 9700: OAuth 2.0 Security Best Current Practice. IETF.
- Fett, D., et al.(2018). FAPI 2.0 Security Profile. OpenID Foundation.
- Keycloak Documentation. https://www.keycloak.org/documentation
- Okta Developer Documentation. https://developer.okta.com/docs/
- Auth0 OIDC Handbook. https://auth0.com/docs/authenticate/protocols/openid-connect-protocol
相关文章:
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
身份与访问控制工程
从 OIDC、OAuth 2.1、SAML、SCIM 到多租户权限、CIAM、PAM 与身份平台选型——系统拆解现代身份与访问控制的协议、架构与工程实践。
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题
【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透
JWT 是现代身份系统事实上的令牌格式,但围绕它的 JWS、JWE、JWK、JWKS 四个 RFC 常常被混为一谈。本文从标准归属、字段细节、算法选型、攻击面、密钥轮换到生产运维,把 JWT 相关的工程问题一次讲透
【身份与访问控制工程】Keycloak 工程拆解:Realm、Client、Flow 与扩展机制
从 Quarkus runtime、Infinispan 缓存、数据库 schema,到 Authentication Flow 引擎、SPI 扩展点、multi-site 部署与常见工程坑点,拆解 Keycloak 的真实工程形态与选型边界。