先说清楚:OIDC 是什么
OAuth 2.0 解决「授权访问」:用户同意后,你的应用拿到一张 access_token,可以调用接口读数据。
但它本身不规定这张令牌代表谁——想知道「当前登录的是哪个用户」,你还得额外调一次接口。
OpenID Connect(OIDC)是架在 OAuth 2.0 之上的一层身份认证标准,它补上三件事:
| 补的东西 | 作用 |
|---|---|
id_token(一个签名的 JWT) |
换令牌时一起返回,里面直接写着 sub(用户唯一标识)、preferred_username 等,你的服务端不调用任何接口就能知道是谁 |
/.well-known/openid-configuration(discovery 文档) |
第三方 SDK 读一次就知道所有端点在哪,不用手动配置 |
jwks_uri(公钥集) |
你用公钥离线验签 id_token,不需要调用我们的接口、也不需要共享密钥 |
一句话:OAuth 管「能读什么」,OIDC 管「是谁」。只做「用 X2Post 登录」的接入方,用 OIDC 更省事(不用每次请求都调我们接口)。
适用与不适用
- 适合:纯登录场景(拿到
id_token建本地账号/会话)、需要标准 SDK(NextAuth、Auth.js、Spring Security 等)零配置接入; - 也可以不做:直接调
GET /api/open/v1/me同样能拿到用户身份,只是多一次请求(见《OAuth 2.0 授权》)。
端点
| 端点 | 地址 | 是否需要令牌 |
|---|---|---|
| Discovery | https://x2post.com/.well-known/openid-configuration |
否 |
| JWKS | https://x2post.com/api/oauth/jwks.json |
否 |
| 授权 | https://x2post.com/oauth/authorize |
用户登录(浏览器) |
| 换令牌 | https://x2post.com/api/oauth/token |
客户端认证 |
| UserInfo | https://x2post.com/api/oauth/userinfo |
Bearer access_token |
| 撤销 | https://x2post.com/api/oauth/revoke |
客户端认证 |
issuer 固定为站点根地址(https://x2post.com),所有地址与 issuer 同域,便于 SDK 校验。
接入流程
在《OAuth 2.0 授权》的授权码流程基础上,只多两件事:
- 授权请求里加
scope=openid(可再带profile:read拿昵称/头像),并带上nonce:
GET https://x2post.com/oauth/authorize
?response_type=code&client_id=<client_id>
&redirect_uri=<回调地址>&scope=openid%20profile:read
&state=<随机串>&nonce=<随机串>
&code_challenge=<PKCE>&code_challenge_method=S256
- 换令牌的响应里会多一个
id_token(申请了openid才有):
{
"access_token": "x2o_...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "x2r_...",
"scope": "openid profile:read",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL3gycG9zdC5jb20iLCJzdWIiOiIxIiwiYXVkIjoieDJjXy4uLiIsImV4cCI6MTc5MDA5NDg4MCwiaWF0IjoxNzkwMDkxMjgwLCJub25jZSI6Ii4uLiIsInByZWZlcnJlZF91c2VybmFtZSI6Im1vYXRrb24ifQ.signature"
}
id_token 的声明
| 声明 | 含义 |
|---|---|
iss |
签发方(站点根地址) |
sub |
用户唯一标识(稳定不变,用它做你的本地账号主键) |
aud |
你的 client_id |
exp / iat / auth_time |
过期、签发、用户认证时间(Unix 秒,有效期 1 小时) |
nonce |
原样回写你授权时传的值(必须比对,用于防重放) |
preferred_username / name / picture |
仅在授权了 profile:read 时返回 |
验签示例(Node.js,零依赖)
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
const discovery = await (await fetch('https://x2post.com/.well-known/openid-configuration')).json();
const { keys } = await (await fetch(discovery.jwks_uri)).json();
export async function verifyIdToken(idToken, expectedNonce) {
const [rawHeader, rawPayload, rawSignature] = idToken.split('.');
const header = JSON.parse(Buffer.from(rawHeader, 'base64url').toString());
const claims = JSON.parse(Buffer.from(rawPayload, 'base64url').toString());
const jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) throw new Error('unknown kid');
const ok = cryptoVerify(
'RSA-SHA256',
Buffer.from(`${rawHeader}.${rawPayload}`),
createPublicKey({ key: jwk, format: 'jwk' }),
Buffer.from(rawSignature, 'base64url'),
);
if (!ok) throw new Error('bad signature');
if (claims.iss !== discovery.issuer) throw new Error('bad issuer');
if (claims.aud !== process.env.X2POST_CLIENT_ID) throw new Error('bad audience');
if (claims.exp * 1000 < Date.now()) throw new Error('expired');
if (expectedNonce && claims.nonce !== expectedNonce) throw new Error('bad nonce');
return claims; // claims.sub 就是用户唯一标识
}
UserInfo
带 access_token 调 GET /api/oauth/userinfo,返回 OIDC 标准声明(sub 一定有,profile:read 时附带 preferred_username / name / picture / bio)。适合 id_token 已过期、但想确认当前用户的场景。
安全清单
- 必须比对
nonce(授权时生成、一次性、与本地会话绑定); - 校验
iss/aud/exp,并用 JWKS 验签(不要跳过验签,也不要接受alg: none); - 用
sub做本地账号主键(用户名可以改,sub不会变); id_token只用于证明身份,读数据仍然用access_token(两者不要混用)。
常见问题
- 换令牌没有
id_token?授权时没带scope=openid。只申请profile:read时只有access_token。 - 支持
client_secret_basic吗?支持,也可以放在表单里(client_secret_post);公共客户端用 PKCE,不需要 secret。 - 密钥会轮换吗?会(例如服务端密钥变更时自动生成新密钥)。历史
id_token在 1 小时有效期内仍可用旧公钥验证,JWKS 会同时给出新旧公钥;请每次按kid取公钥,不要缓存固定公钥。