--- tags: [sso, oidc, jwt, auth] create time: 2026-07-13 10:03 --- # OpenID Connect (OIDC) ## 概述 在 [[sso/sso-oauth2|OAuth 2.0]] 中我们知道,授权码模式拿到的 `access_token` 只能调 API,但**不知道登录的用户是谁**——OAuth 2.0 只解决「授权」,不解决「认证」。 **OIDC(OpenID Connect)** 在 OAuth 2.0 之上补了认证这一层。它增加了一个 **ID Token**,里面直接写着「登录的用户是张三,邮箱是 zhangsan@example.com」。 > [!info] 一个类比 > OAuth 2.0 像门禁系统——它验证你有权限进入大楼,但不记录你是谁。OIDC 在门禁旁边加了一个人脸识别摄像头——既验证权限,又知道是谁进来了。 OIDC 是当前最主流的 SSO 协议。Keycloak、Auth0、Okta、Azure AD、Google 等都支持。 ## OIDC vs OAuth 2.0:到底多了什么? | 维度 | OAuth 2.0 | OIDC | |------|-----------|------| | 解决什么问题 | 授权(你能访问什么) | 认证 + 授权(你是谁 + 你能访问什么) | | 返回的 Token | `access_token`, `refresh_token | **+ `id_token`**(用户身份信息) | | 用户信息怎么拿 | 需要额外调 `/userinfo` 接口 | `id_token` 自带基本信息,`/userinfo` 补充详细信息 | | scope 要求 | 自定义 | 必须包含 `openid`,标准 scope:`profile`、`email`、`address`、`phone` | | 协议规范 | RFC 6749 | OpenID Connect Core 1.0 | ## 先理解 JWT ID Token 是一个 **JWT(JSON Web Token)**,所以先搞清楚 JWT 是什么。 JWT 是一种**自包含的签名令牌**,由三部分用 `.` 连接组成: ``` eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1In0.签名内容 ├── Header (Base64) ──┤├── Payload (Base64) ───┤├─ Signature ─┤ ``` **三部分分别是什么:** | 部分 | 内容 | 说明 | |------|------|------| | **Header** | 算法和类型 | `{"alg": "RS256", "typ": "JWT"}` — 说明用什么算法签名 | | **Payload** | 用户信息和元数据 | `{"sub": "user-123", "name": "张三"}` — **这是 Claim(声明)** | | **Signature** | 签名 | 用 IdP 的私钥对前两部分签名,防止被篡改 | > [!warning] JWT 的 Payload 不是加密的! > Base64 只是编码,不是加密。任何人都能解码 Payload 看到内容。所以**不要在 JWT 里放密码、手机号等敏感信息**。JWT 的安全靠的是签名——你可以读,但改不了(改了签名就对不上)。 ## 核心流程 OIDC 的流程和 OAuth 2.0 授权码模式**几乎一样**,区别只有两点: 1. scope 必须包含 `openid` 2. Token 响应里多了 `id_token` ```mermaid sequenceDiagram participant U as 用户 (浏览器) participant App as 业务应用 (Client/RP) participant IdP as 认证中心 (OpenID Provider) U->>App: 1. 点击「登录」 App->>U: 2. 浏览器跳转到 IdP Note right of App: 比 OAuth 2.0 多了 scope=openid U->>IdP: 3. 在 IdP 登录并授权 IdP->>U: 4. 浏览器跳回,携带 code U->>App: 5. 回调 App->>IdP: 6. 后端用 code 换 Token IdP-->>App: 7. 返回 access_token + id_token + refresh_token Note right of IdP: id_token 是新增的!
它包含用户的身份信息 App->>App: 8. 验证 id_token 的签名和关键字段 App-->>U: 9. 登录成功 ``` > [!tip] 术语对照 > OIDC 里把 Client 叫 **RP(Relying Party,依赖方)**,把授权服务器叫 **OpenID Provider(OP)**。本质和 OAuth 2.0 的 Client / Authorization Server 是一回事,只是换了个名字。本文统一用「应用」和「IdP」。 ## ID Token 详解 ID Token 是一个 JWT,包含了用户的身份信息。下面是实际的 ID Token 解码后的样子: ### Header ```json { "alg": "RS256", // 签名算法:RSA + SHA256 "typ": "JWT", "kid": "key-2026-07" // 密钥 ID,用于查找对应的公钥 } ``` ### Payload(Claims) **Claim(声明)** 就是一个键值对,表示关于用户的某个事实,比如「名字叫张三」就是一个 Claim。 ```json { "iss": "https://idp.example.com", // 签发者:谁签发了这个 Token "sub": "user-12345", // 主题:用户的唯一标识 "aud": "your-client-id", // 受众:这个 Token 是给哪个应用的 "exp": 1752374580, // 过期时间(Unix 时间戳) "iat": 1752374280, // 签发时间 "nonce": "abc123", // 防重放的随机值 "name": "张三", // 以下是用户信息 "email": "zhangsan@example.com", "email_verified": true, "picture": "https://cdn.example.com/avatar.jpg" } ``` ### 必须校验的 Claims > [!danger] 不校验 = 门户大开 > 收到 ID Token 后,必须逐项校验以下字段。跳过任何一个都可能被攻击者伪造身份。 | Claim | 怎么校验 | 不校验会怎样 | |-------|----------|-------------| | `iss` | 必须等于你配置的 IdP 地址 | 攻击者自己搭一个假 IdP 签发 Token | | `aud` | 必须包含你的 `client_id` | Token 被别的应用偷去用 | | `exp` | 当前时间必须 < `exp` | 过期 Token 仍能登录 | | `nonce` | 必须和你发起请求时存的值一致 | 攻击者截获 Token 后重放使用 | | `signature` | 用 IdP 的公钥验证签名 | Token 内容被篡改你发现不了 | ### Go 校验示例 ```go import ( "context" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) ctx := context.Background() // 1. 通过 OIDC Discovery 自动获取 IdP 的所有端点和公钥 // 这会访问 https://idp.example.com/.well-known/openid-configuration provider, err := oidc.NewProvider(ctx, "https://idp.example.com") if err != nil { log.Fatal("无法连接 IdP:", err) } // 2. 创建 ID Token 校验器(自动校验 iss、aud、签名) verifier := provider.Verifier(&oidc.Config{ ClientID: "your-client-id", }) // 3. 校验 rawIDToken(从 Token 响应中拿到的 id_token 字符串) // 校验内容:签名有效性、iss、aud、exp idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil { log.Fatal("ID Token 校验失败:", err) } // 4. 校验 nonce(手动校验,库不会帮你做) if idToken.Nonce != expectedNonce { log.Fatal("nonce 不匹配,可能是重放攻击") } // 5. 提取用户信息 var claims struct { Name string `json:"name"` Email string `json:"email"` } if err := idToken.Claims(&claims); err != nil { log.Fatal("解析 Claims 失败:", err) } fmt.Printf("登录用户:%s (%s)\n", claims.Name, claims.Email) ``` > [!tip] 库是怎么自动拿到 IdP 公钥的? > `oidc.NewProvider()` 会访问 IdP 的 Discovery 端点(`/.well-known/openid-configuration`),从中获取 `jwks_uri`(公钥端点),再拉取公钥并缓存。你只需要提供 IdP 的 URL,其他全自动。 ## OIDC Discovery:自动发现 IdP 配置 每个 OIDC Provider 都暴露一个**发现端点**,让客户端自动获取所有配置,不需要手动填写各种 URL: ``` GET https://idp.example.com/.well-known/openid-configuration ``` 返回: ```json { "issuer": "https://idp.example.com", // IdP 身份标识 "authorization_endpoint": "https://idp.example.com/oauth/authorize", // 登录授权页面 "token_endpoint": "https://idp.example.com/oauth/token", // 换 Token 的接口 "userinfo_endpoint": "https://idp.example.com/userinfo", // 获取用户详情的接口 "jwks_uri": "https://idp.example.com/.well-known/jwks.json", // 公钥端点,用于验签 "scopes_supported": ["openid", "profile", "email"], // 支持的 scope "response_types_supported": ["code"], // 支持的响应类型 "id_token_signing_alg_values_supported": ["RS256", "ES256"] // 支持的签名算法 } ``` > **客户端只需要知道 IdP 的 URL**,其他所有端点地址都通过 Discovery 自动获取。这是 OIDC 比 SAML 接入成本低的一个重要原因。 ## 常见陷阱与最佳实践 ### 必须校验 ID Token 签名 用 IdP 的公钥验证签名。`coreos/go-oidc` 会自动从 JWKS 端点获取和缓存公钥,不需要你手动管理。**不要只 Base64 解码就信任 Payload——签名没验证等于没验证。** ### nonce 是防重放的关键 流程: 1. 用户点击登录时,生成随机 `nonce` 存入 session 2. 把 `nonce` 放进授权请求 3. IdP 会把 `nonce` 原样写进 ID Token 4. 回调时比对 Token 中的 `nonce` 和 session 中的是否一致 如果攻击者截获了你的 ID Token,想在另一个浏览器重放,`nonce` 会对不上(因为 session 里存的不一样)。 ### 不要在 ID Token 里放敏感信息 ID Token 可能被前端 JavaScript 直接读取(取决于你的架构)。密码、手机号、身份证号等不要放进去。需要这些信息时,用 `access_token` 调 `/userinfo` 接口获取。 ### 密钥轮换(Key Rollover) IdP 会定期更换签名密钥。客户端库需要通过 `kid`(Key ID)匹配正确的公钥。`coreos/go-oidc` 会自动处理,但**不要硬编码公钥**。 ### Google 的 `hd` 参数(Google 专属) > [!warning] 这是 Google 的扩展参数,不是 OIDC 标准 > Google OIDC 支持 `hd`(hosted domain)参数,限制只允许特定域名(如 `company.com`)的账号登录。企业 SSO 场景务必加上,否则用户可能用个人 Gmail 登录。