先说清楚: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 授权》的授权码流程基础上,只多两件事:

  1. 授权请求里加 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
  1. 换令牌的响应里会多一个 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 取公钥,不要缓存固定公钥。