土法炼钢兴趣小组的算法知识备份

【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO

文章导航

分类入口
architecturesecurity
标签入口
#SSO#OIDC#OAuth2#OpenID Connect#id_token#JWKS#enterprise

目录

上一篇: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 开通接口规格。通过安全评审后合同才能生效。」

OIDC 授权码流程

工程团队翻出产品代码,登录功能是两年前一个实习生写的:前端 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 是最底层的那一个。原因很简单:企业的员工生命周期管理(入职、转岗、离职)都在他们自己的身份源里,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 定义了四个角色:

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_tokenaccess_token 的本质区别可以用一句话概括:

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"
}

逐项说明每个字段为什么存在:

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
}

工程上值得单独拎出来的字段:

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"
    }
  ]
}

关键工程约束:

3.5 Session Management 与 Logout

OAuth 2.0 没有 Logout 概念,因为它本来就不管登录。OIDC 补了三种 Logout 方式,分别解决不同的工程约束:

企业场景里 Back-Channel 是最可靠的,因为它不依赖用户浏览器开着;Front-Channel 依赖 iframe 跨域能力,在 Safari ITP、第三方 Cookie 禁用的大趋势下基本失效;RP-Initiated 只能清掉用户主动点「退出」的那一个 RP,其他 RP 下次请求时才会被动发现登录态丢失。

3.6 Dynamic Client Registration(RFC 7591)

大部分企业场景里 Client 是静态注册的——管理员在 OP 控制台填表,拿到一个 client_idclient_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_idregistration_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 生成 statenoncecode_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

参数的工程含义:

4.2 第 3 – 6 步:OP 完成认证

OP 收到 /authorize 请求后执行的内部流程:

  1. 校验 client_id 存在、redirect_uri 在注册时的白名单内、scope 合法。
  2. 检查用户的 OP Session Cookie,未登录则展示登录页(密码、passkey、SAML 联邦、AD 联邦等)。
  3. 要求用户同意 scope 中列出的权限(首次登录时,之后可记住)。
  4. 执行 MFA 策略(如果策略要求)。
  5. 生成一个一次性、短期的 Authorization Code(通常寿命 30 秒到 2 分钟),把 noncecode_challengeredirect_uriuser_id 等绑定到这个 code 上存入服务端。
  6. 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 自己决定的。三种选择:

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 验完签后按 sidsub 主动清本地会话。这种方式不依赖浏览器,是企业 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/callbackhttps://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。完整工作流:

  1. 租户管理员在 SaaS 控制台点「接入 Okta SSO」,填入 Issuer URL(例如 https://acme.okta.com)。
  2. SaaS 后端拉取 https://acme.okta.com/.well-known/openid-configuration,检查 registration_endpoint 是否存在。
  3. 如果存在,SaaS 用 initial access token(由租户管理员提前在 Okta 里生成)调用 /register,自助注册一个 client,拿到 client_id
  4. 如果不存在,SaaS 引导租户管理员在 Okta 控制台手工创建 OIDC App、回填 client_id
  5. SaaS 后端根据租户邮箱域名做 Home Realm Discovery:alice@acme.com 域名为 acme.com,查到该租户的 Issuer,就走对应 OIDC 流程。
  6. 登录成功后 SCIM 2.0(下一篇详见)把员工信息同步过来,做本地账号与 OIDC sub 的绑定。

9.1 企业租户接入 checklist

真正面向企业客户开放「自助接入 OIDC」时,最好把下面这张清单产品化到控制台,而不是让售前或工程师拿文档手工核对:

检查项 要求 说明
Issuer URL 必填,必须可访问 用于拉取 Discovery 文档
Discovery 文档 必须成功拉取并缓存 校验 authorization_endpointtoken_endpointjwks_uriissuer 一致
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 客户端。用成熟库:

选库的三条标准:是否做 id_token 完整验证(包括 at_hash、c_hash)、是否内置 JWKS 缓存与轮换、是否支持 PKCE。满足这三点以外的「自己写一遍加深理解」基本都是 over-engineering。

10.3 协议版本与兼容


十一、总结

OIDC 的协议表面看只是 OAuth 2.0 加了一个 JWT,但它真正的价值在于把「企业身份接入」标准化为一次性工程投入。接入第一家 Okta 客户可能花两周,但有了正确实现后,接入第二家 Entra、第三家 OneLogin、第四家 Keycloak,基本只剩「租户管理员填一个 Issuer URL」的工作量。

工程上需要牢牢记住几件事:

认证架构的演进方向不是「淘汰」,而是「分层」。OIDC 不会替换掉 Session(OP 内部还是 Session 管登录态),也不会替换掉 JWT(id_token 本身就是 JWT),它在更高的抽象层上解决「跨组织身份互通」的问题。后续几篇会在这个基础上继续往下挖:第 3 篇讲 OAuth 2.1 和 PKCE 的协议新约束,第 4 篇补齐 SAML 的企业现实,第 5 篇讲 SCIM 把账号生命周期自动化起来,第 6 篇把 JWT/JWS/JWE/JWKS 一次讲透。


十二、参考资料

  1. Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., Mortimore, C.(2014). OpenID Connect Core 1.0. OpenID Foundation.
  2. Sakimura, N., Bradley, J., Jones, M.(2014). OpenID Connect Discovery 1.0. OpenID Foundation.
  3. Sakimura, N., Bradley, J., Jones, M.(2014). OpenID Connect Dynamic Client Registration 1.0. OpenID Foundation.
  4. Jones, M., Bradley, J., Sakimura, N.(2015). RFC 7519: JSON Web Token (JWT). IETF.
  5. Hardt, D.(2012). RFC 6749: The OAuth 2.0 Authorization Framework. IETF.
  6. Richer, J., Jones, M., Bradley, J., Machulak, M., Hunt, P.(2015). RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol. IETF.
  7. Campbell, B., Bradley, J., Sakimura, N., Lodderstedt, T.(2020). RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens. IETF.
  8. Richer, J.(2015). RFC 7662: OAuth 2.0 Token Introspection. IETF.
  9. Jones, M., Bradley, J.(2022). OpenID Connect Back-Channel Logout 1.0. OpenID Foundation.
  10. Jones, M., Bradley, J.(2022). OpenID Connect Front-Channel Logout 1.0. OpenID Foundation.
  11. Lodderstedt, T., Bradley, J., Labunets, A., Fett, D.(2020). RFC 6819 / RFC 9700: OAuth 2.0 Security Best Current Practice. IETF.
  12. Fett, D., et al.(2018). FAPI 2.0 Security Profile. OpenID Foundation.
  13. Keycloak Documentation. https://www.keycloak.org/documentation
  14. Okta Developer Documentation. https://developer.okta.com/docs/
  15. Auth0 OIDC Handbook. https://auth0.com/docs/authenticate/protocols/openid-connect-protocol

上一篇IAM 全景:为什么这是高价值赛道

下一篇OAuth 2.1 与 PKCE:现代授权主路径

相关文章:

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-21 · architecture / security

身份与访问控制工程

从 OIDC、OAuth 2.1、SAML、SCIM 到多租户权限、CIAM、PAM 与身份平台选型——系统拆解现代身份与访问控制的协议、架构与工程实践。

2026-04-21 · architecture / security

【身份与访问控制工程】JWT、JWS、JWE、JWKS 一次讲透

JWT 是现代身份系统事实上的令牌格式,但围绕它的 JWS、JWE、JWK、JWKS 四个 RFC 常常被混为一谈。本文从标准归属、字段细节、算法选型、攻击面、密钥轮换到生产运维,把 JWT 相关的工程问题一次讲透


By .