OpenID Connect
这一课你将能做到
- 说清 OIDC 与 OAuth 2.0 的关系(OIDC = OAuth 2.0 + 标准化身份层),区分 access_token 与 id_token 的受众和用途
- 解码 id_token 的 payload,列出并执行五项必检:签名、iss、aud、exp、nonce
- 实现联邦登录的落库与发本站 JWT:验 id_token → 用 (iss, sub) 查找或创建本地用户 → backend 签发站内 JWT
为什么要学
OAuth 2.0 回答的是:「这个 App 被允许访问哪些资源?」但产品要的「用 Google 登录」还要回答另一个问题:「眼前这个用户是谁?邮箱是多少?要不要给他建个号?」
纯 OAuth 没有「登录成功」的标准表达:你可以拿 access_token 去调用户信息接口,但每个平台返回的字段、格式、含义都不一样(GitHub 返回 id,微信返回 openid,新浪返回 uid……),每接一个平台就要写一套适配。
OIDC(OpenID Connect)在 OAuth 2.0 之上定义了标准身份层:
- scope 里带
openid,token 端点就多返回一份 id_token(标准 JWT);
- id_token 里的 claims(
sub 用户唯一标识、email、name…)格式统一;
- 另有标准 UserInfo 端点、Discovery 元数据(
/.well-known/openid-configuration)。
Google、Auth0、Keycloak、企业 SSO 全都按 OIDC 来。面试必考:「OIDC 和 OAuth 什么关系?」——背:OIDC = OAuth 2.0 + 身份层;OAuth 管授权(能访问什么),OIDC 管认证(你是谁);OIDC 完全构建在 OAuth 流程之上,id_token 是它最核心的新增产物。
先建立图像
纯 OAuth(授权 API):
用户同意 → access_token → Client 调 Resource Server 的 API
「这个 App 可以读我的资料」——但「资料主人」没有标准身份格式
OIDC(认证 + 可选授权):
scope=openid profile email
用户同意 → access_token + id_token
│ │
│ └─► Client 验签后相信:sub=110169... 就是登录者
│ (id_token 是「给 Client 看的身份证」)
└─► Client 用 access_token 调 API / UserInfo
三种 token 各归各位:
| 产物 | 给谁看 | 典型格式 | 用途 |
|---|
| access_token | Resource Server | 不透明串或 JWT | 开门调 API |
| id_token | Client 自己 | 一定是 JWT | 证明「登录的是谁」 |
| refresh_token | Client(妥善保管) | 通常不透明 | 换新 access_token |
比喻:access_token 是房卡(给门锁看);id_token 是前台给你的「已核实身份回执」(给你自己看,门锁不认它)。
id_token 的 payload 长这样:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "your-client-id",
"exp": 1735689600,
"iat": 1735686000,
"nonce": "a1b2c3d4",
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Wang",
"picture": "https://lh3.googleusercontent.com/..."
}
跟着做
步骤 1:authorize 时带上 openid
GET https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=你的ClientID
&redirect_uri=http://localhost:8080/login/oauth2/code/google
&scope=openid%20email%20profile
&state=随机串
&nonce=加密随机串(防重放)
记住一条规则:scope 里没有 openid,就没有标准 id_token——这是 OIDC 的总开关。
步骤 2:亲手解码一份 id_token
token 端点响应里的 id_token 是三段式 header.payload.signature。把 payload(第二段)做 Base64URL 解码:
// 中文注释:仅本地解码查看字段;生产校验必须验签,绝不能只解码
const payload = JSON.parse(
Buffer.from(idToken.split(".")[1], "base64url").toString()
);
console.log(payload);
对照上面的 JSON,找出 iss、sub、aud、exp、nonce 五个字段。
步骤 3:写出 Java 校验(骨架完整可落地)
// 中文注释:id_token 校验五件事——验签、iss、aud、exp、nonce;任何一项失败按未登录处理
// 实际项目推荐用 nimbus-jose-jwt 或 Spring Security 的 JwtDecoder(内置 JWKS 验签)
public void validateIdTokenClaims(Jwt idToken, String expectedNonce) {
Instant now = Instant.now();
if (idToken.getExpiresAt() == null || idToken.getExpiresAt().isBefore(now)) {
throw new BadJwtException("id_token 已过期");
}
if (!"https://accounts.google.com".equals(idToken.getIssuer().toString())) {
throw new BadJwtException("iss 不是预期的 IdP");
}
if (!idToken.getAudience().contains(clientId)) {
throw new BadJwtException("aud 不是发给我这个 Client 的");
}
if (expectedNonce != null
&& !expectedNonce.equals(idToken.getClaimAsString("nonce"))) {
throw new BadJwtException("nonce 不匹配,疑似重放");
}
}
| 检查项 | 防的攻击 |
|---|
| 验签(JWKS 公钥) | 伪造 token——任何人都能 base64 编一段假 payload |
| iss | 拿「别的 IdP 签发的合法 token」来冒充 |
| aud | 拿「发给别的应用」的 token 来你这里充数 |
| exp | 重放过期旧 token |
| nonce | 把某个会话的登录凭证重放进另一个会话 |
步骤 4:可选的 UserInfo 端点
# 中文注释:某些 claim(如 email)可能只在 UserInfo 返回;用 access_token 调用
curl -i https://openidconnect.googleapis.com/v1/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"
步骤 5:映射到本站用户并发本站 JWT
// 中文注释:联邦登录三步——找/建用户 → 发本站 JWT → 跳回前端
User user = userService.findByOidc(issuer, sub)
.orElseGet(() -> userService.createOidcUser(issuer, sub, email, name));
String siteToken = jwtUtil.sign(user.getId(), user.getRole()); // 本站自研 JWT
String redirect = frontendUrl + "/login/callback?token="
+ URLEncoder.encode(siteToken, StandardCharsets.UTF_8);
response.sendRedirect(redirect);
-- 中文注释:provider + external_id 联合唯一;email 只当资料不当主键
ALTER TABLE user_oauth_binding
ADD UNIQUE KEY uk_provider_subject (provider, external_id);
步骤 6:对比 GitHub(没有 OIDC 的平台)
GitHub:access_token → GET api.github.com/user → 拿数字 id 当 sub 用
Google:id_token 直接读 sub(可验签,不必再调接口)
统一适配层:provider + externalId 两字段把不同平台抹平
讲清楚要点
- OAuth 不定义身份,OIDC 补上:
openid scope 是开关,id_token 是产物,UserInfo 是补充通道。
- id_token 的受众是 Client,不是 Resource Server:不要拿 id_token 去 API 当 Bearer;API 只认 access_token(或本站自己的凭证)。
- 五项校验缺一不可:验签、iss、aud、exp、nonce。只解码不验签等于没校验——payload 谁都能编。
- sub 是同一位 IdP 下用户的稳定主键:本地表用 (provider, external_id) 联合唯一;email 可能变更、可能未验证,不能当主键。
- 登录成功 ≠ 拿到 IdP 的 token 就完事:标准做法是映射本地用户、发本站会话凭证(本站 JWT),站内权限与撤销不依赖 IdP。
- Discovery 简化接入:从
https://IdP域名/.well-known/openid-configuration 获取 authorize/token/jwks 端点,别硬编码。
你可能会懵的地方
-
既然拿到了 access_token,为什么还要 id_token?
access_token 可能是不透明串,Client 解析不了「是谁」;就算能解析,语义也是资源服务器说了算。id_token 是专门给 Client 的标准化身份断言,格式固定、可验签。
-
id_token 可以当 Bearer 调自己的 API 吗?
不建议。aud 语义就错了(发给 Client 不是发给 API),安全模型会混乱。本站 API 继续用本站 JWT;调 IdP 的 API 用 IdP 的 access_token。
-
时钟偏差会不会把好 token 判成过期?
会有几十秒级别的偏差问题。校验库一般允许 30-60 秒 clock skew,服务器保持 NTP 对时。
-
一个用户有 GitHub 和 Google 两个账号,怎么合成一个本地用户?
产品决策:常见做法是登录后提供「绑定其他账号」入口(已登录状态下绑定并验证邮箱),而不是首次登录时按 email 静默合并——email 验证状态不可信时,静默合并会被用来接管账号。
-
nonce 到底防什么?
防重放:攻击者诱导你的应用用「他准备好的 code/token」完成登录(login CSRF 的变体)。authorize 时生成随机 nonce 存会话,id_token 里的 nonce 必须与会话里那份一致,确保这份 id_token 是「本次登录流程」的产物。
错法对照
| 错法 | 问题 | 正解 |
|---|
| base64 解码 id_token 后直接用,不验签 | 伪造 token 畅通无阻 | JWKS 验签 + iss/aud/exp/nonce 五检 |
| 用 email 当本地用户唯一键 | email 可改、可未验证、可被他人注册 | (provider, external_id) 唯一,email 只作资料 |
| 把 id_token 塞进 Authorization 头调业务 API | aud 语义错误,安全模型混乱 | 业务 API 用本站 JWT;IdP API 用其 access_token |
| 以为「配了 OAuth 就自动是 OIDC」 | 少了 openid scope,拿不到标准 id_token | 检查 scope 与 id_token 是否真的返回 |
| 忘了生成/校验 nonce | 回调可被重放进别的会话 | authorize 时生成随机 nonce,回调严格比对 |
卡点急救
- 现象:token 端点正常返回,但代码里拿不到 id_token。检查 authorize 的 scope 是否包含
openid(漏了就没有);确认响应 JSON 字段名是 id_token;GitHub 这类非 OIDC 平台本来就没有,别按 OIDC 解析。
- 现象:验签失败 / signature validation failed。JWKS 地址是否正确(先 curl
/.well-known/openid-configuration);token 的 kid 能否在 JWKS 里找到(IdP 轮换密钥后旧缓存要刷新);本地时间是否准确。
- 现象:同一用户每次登录都新建本地用户。查 (provider, external_id) 唯一键是否建了;取 external_id 的字段是否稳定(GitHub 用数字 id,不要用会改名的 login;Google 用 sub)。
面试常问
-
OIDC 和 OAuth 2.0 是什么关系?
OIDC 是构建在 OAuth 2.0 之上的身份认证层。OAuth 管授权(App 能访问什么资源),OIDC 管认证(用户是谁)。技术上:scope 带 openid → token 响应多出 id_token(JWT);另有 UserInfo 端点与 Discovery。一句话:OAuth 问「能做什么」,OIDC 问「你是谁」。
-
id_token 和 access_token 的区别?
受众不同:access_token 给资源服务器验(开门),id_token 给客户端验(认人)。格式上 id_token 必为 JWT,access_token 可以是 JWT 也可以是不透明串。绝不能混用——拿 id_token 调 API、拿 access_token 解析身份都是错误姿势。
-
第三方登录后为什么要发本站自己的 JWT,而不是直接用 IdP 的 token?
三点:站内权限模型(角色、会员状态)在本地库,IdP 不知道;撤销可控——本站 JWT 短寿命 + 会话版本,不依赖 IdP 可用性;解耦——换 IdP 或 IdP 故障时站内会话不受牵连。
自测三问
- id_token 的五项必检是什么?各防什么攻击?
- access_token、id_token、refresh_token 分别给谁用?
- 本地用户表为什么用 (provider, external_id) 而不是 email 做唯一键?
小结一句话
OIDC = OAuth 2.0 + 标准身份层:openid scope 开关、id_token 身份断言、UserInfo 补充;Client 验 id_token 认人,API 验 access_token 开门。
延伸