13. 协议端点线格式标准化迁移指南(第三方必读)
一句话:
/oauth/token、/oauth/userinfo、/oauth/revoke三个端点的响应,将从我们自家的{code, message, data}信封改为 RFC 6749 / RFC 6750 标准裸 JSON。如果你的代码里写过res.data.access_token或if (res.code !== 0),切换当天会断。本文给出一条零停机的迁移路径:先上「双格式兼容读取器」(切换前后都能用),等所有站点确认 完成后我们再切换,之后你可以把兼容分支删掉。
1. 为什么要改
我们的 OAuth 服务同时是一个 OIDC Provider。但协议端点一直套着自家的 {code,message,data} 信封,
而任何标准 OIDC 客户端库都是从 JSON 顶层读 access_token 的 —— 读不到就当作失败。
后果是双向的:
- 想用标准库(openid-client、better-auth genericOAuth、AppAuth、go-oidc……)接我们的人,根本接不进来。 近期就有一个接入方卡在这里,重试了 33 次都拿不到 token。
- 已经接进来的人,全都被迫写了自定义拆信封代码 —— 也就是说,信封逼着每一个合作方都写了非标准代码。
所以这次是把线格式修正到规范上:标准库开箱即用,自定义代码可以删掉。
2. 改什么(精确的前后对照)
2.1 POST /oauth/token 成功
现在
{
"code": 0,
"message": "成功",
"data": {
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "...",
"scope": "openid profile email",
"id_token": "eyJ..."
}
}
之后(data 里的对象原样提到顶层,字段名和含义完全不变)
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "...",
"scope": "openid profile email",
"id_token": "eyJ..."
}
2.2 POST /oauth/token 失败
现在:HTTP 400,{"code": 15003, "message": "无效的授权码"}
之后:RFC 6749 §5.2 错误对象
{ "error": "invalid_grant", "error_description": "无效的授权码" }
error |
HTTP | 原错误码 | 你该怎么处理 |
|---|---|---|---|
invalid_client |
401 | 15001 / 15008 | client_id / secret 配置错 → 凭据已死 |
invalid_grant |
400 | 15002 / 15003 / 15004 / 10003 / 10014 | 授权码或 refresh token 无效 → 要求重新登录 |
unauthorized_client |
400 | 15005 | 该 client 未开启此 grant → 联系我们 |
invalid_scope |
400 | 15006 | scope 不在允许范围 |
unsupported_grant_type |
400 | 15011 | 请求写错了 |
invalid_request |
400 | 其他 | 请求格式问题 |
server_error |
500 | — | 我们的内部故障 → 瞬态,请保留会话并重试 |
2.3 GET /oauth/userinfo
成功同理,data 提到顶层:
{ "id": 1007, "sub": "uuid...", "name": "kun", "email": "...", "picture": "...", "roles": ["user"] }
失败改为 RFC 6750 §3(OIDC Core §5.3.3 的要求):响应带 WWW-Authenticate 头,body 同形状。
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="kungal", error="invalid_token", error_description="..."
{ "error": "invalid_token", "error_description": "..." }
⚠️ 封禁是 HTTP 403。RFC 6750 没有表示「账号被封」的错误码,所以我们用状态码表达。 如果你有单独的「账号已封禁」页面,请按 403 判定,否则它会退化成「重新登录 → 又被拒」的死循环。
2.4 POST /oauth/revoke
按 RFC 7009 §2.2 返回 HTTP 200 且 body 为空(以前是 {"code":0,...})。
如果你的代码无条件 res.json(),空 body 会抛异常 —— 请先判断 body 是否为空。
2.5 顺带修好的三件事(不需要你改,但值得知道)
Authorization头的 scheme 现在大小写不敏感(RFC 7235 §2.1)。以前bearer xxx(小写)会被拒, 而且报的是「令牌无效」——如果你曾经因为这个查过问题,就是它。- 所有 401/403 现在都带
WWW-Authenticate头,你可以据此区分「token 该刷新了」和「请求写错了」。 - refresh 时也会签发
id_token了(OIDC Core §12.2)。之前有些库因为刷新拿不到 id_token, 会误判成「会话结束」而强制用户每 15 分钟重新登录一次。
3. 零停机迁移路径
⚠️ 请不要现在就直接改成只读标准格式 —— 我们还没切换,你会立刻断掉。
正确顺序是三步(和我们自家站点走的是同一条路):
第 1 步(你,现在) 上线「双格式兼容读取器」——切换前后都能工作
第 2 步(我们,等你们都完成后) 切换线格式
第 3 步(你,之后随时) 删掉信封分支,或直接换成标准 OIDC 库
判别方法只有一条
看响应里有没有 code 这个 key:
- 有
code→ 是旧信封,payload 在data里 - 没有
code→ 是标准格式,整个 body 就是 payload;失败时带error/error_description
TypeScript / JavaScript
// 兼容读取器:切换前后都正确。切换完成后,删掉标有 LEGACY 的那一段即可。
const readOAuthBody = <T>(body: unknown, status: number): T => {
if (body === null || typeof body !== 'object') {
throw new Error(`OAuth 返回了非 JSON 响应 (HTTP ${status})`)
}
const obj = body as Record<string, unknown>
// LEGACY: `code` 存在 ⇒ 旧信封
if (typeof obj.code === 'number') {
if (obj.code !== 0) throw new Error(`OAuth 错误 ${obj.code}: ${String(obj.message ?? '')}`)
return obj.data as T
}
// 标准格式
if (typeof obj.error === 'string') {
throw new Error(`${String(obj.error)}: ${String(obj.error_description ?? '')}`)
}
if (status >= 400) throw new Error(`OAuth 返回 HTTP ${status}`)
return obj as T
}
// /oauth/revoke 在标准格式下是 200 空 body,单独处理
const readOAuthResponse = async <T>(res: Response): Promise<T> => {
const text = await res.text()
if (text === '') {
if (res.ok) return undefined as T
throw new Error(`OAuth 返回空 body (HTTP ${res.status})`)
}
return readOAuthBody<T>(JSON.parse(text), res.status)
}
Go
// probe 只用来判形状,判完再按对应分支解析真正的 payload。
var probe struct {
Code *int `json:"code"` // 指针:nil ⇒ 字段不存在 ⇒ 标准格式
Message string `json:"message"`
Data json.RawMessage `json:"data"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(raw, &probe); err != nil { /* ... */ }
payload := raw // 标准格式下,整个 body 就是 payload
switch {
case probe.Code != nil: // LEGACY
if *probe.Code != 0 {
return fmt.Errorf("oauth code=%d msg=%s", *probe.Code, probe.Message)
}
payload = probe.Data
case probe.Error != "":
return fmt.Errorf("oauth %s: %s", probe.Error, probe.ErrorDescription)
}
var tok TokenResponse
_ = json.Unmarshal(payload, &tok)
Kotlin / Android(public client)
同样先看 code 是否存在。kotlinx.serialization 可以把 code 声明成可空字段
(val code: Int? = null),非空即旧信封。
用 better-auth / Auth.js genericOAuth 的(紫缘社、AniBT 的回调路径像是这一类)
如果你为了拆信封重写了 getUserInfo / mapProfileToUser,切换后可以整段删掉,
换成标准配置指向:
https://oauth.kungal.com/.well-known/openid-configuration
但删除动作请放到第 3 步(我们切换之后),否则会提前断。
4. 三个必须做对的判定(这是实际会出事的地方)
我们自家站点在这次迁移里踩过,列出来供你对照:
invalid_token必须被识别成「凭据已死」。 如果你的映射表里漏了它,它会落到「未知错误 → 瞬态 → 重试」分支,结果是抱着永久失效的 token 无限重试,永远不重新认证。- 只有未知错误和 5xx 才算瞬态。
invalid_grant/invalid_client/unauthorized_client/invalid_token一律清会话、要求重新登录。 server_error是 500,不是 4xx。 请务必按 HTTP 状态码区分:5xx = 我们抖了,请保留用户会话 并重试;4xx = 对凭据的判决。把 5xx 当成 4xx 处理,会在我们一次内部故障时把你的用户全部登出。
5. 你可以现在就自测
兼容读取器是一个纯函数,不需要等我们切换 —— 直接用下面两组 body 做单元测试,两组都要能解析出
access_token = "TOK":
// 旧信封(今天线上的真实形状)
{"code":0,"message":"成功","data":{"access_token":"TOK","token_type":"Bearer","expires_in":900,"refresh_token":"REF","scope":"openid profile email"}}
// 标准格式(切换后的形状)
{"access_token":"TOK","token_type":"Bearer","expires_in":900,"refresh_token":"REF","scope":"openid profile email"}
错误分支同理,两组都要判成「凭据已死」:
{"code":15003,"message":"无效的授权码"} // 旧
{"error":"invalid_grant","error_description":"无效的授权码"} // 新
另外,今天就可以直接对生产验证 discovery 和 JWKS(这两个端点一直是标准格式,不受本次影响):
curl -s https://oauth.kungal.com/.well-known/openid-configuration | jq .
curl -s https://oauth.kungal.com/oauth/jwks | jq .
6. 我们需要你回复什么
在开发群里回一句即可:
{站点名}:兼容读取器已上线 ✅ / 预计 {日期} 上线
六家站点全部确认后我们才会切换,切换时间会提前在群里公告。切换本身是一次部署,秒级完成; 如果出现意外,我们可以回滚(回滚会让已经改成只读标准格式的站点受影响,所以请务必在第 3 步之前 保留兼容分支)。
7. 接 Auth0 的(Custom Social Connection 的「Fetch User Profile Script」)
如果你是把我们接进 Auth0,Auth0 会要你填一段 Fetch User Profile Script。
先看看你能不能不写这段脚本
我们现在是完整的标准 OIDC Provider(discovery + JWKS + id_token + 规范 token/userinfo)。 Auth0 的 Enterprise → OpenID Connect 连接类型只需要填 discovery 地址,不需要任何脚本:
https://oauth.kungal.com/.well-known/openid-configuration
这是推荐做法 —— 没有自定义代码就没有会过期的自定义代码。
如果你已经建成 Custom Social Connection
那就必须填脚本。配置:
| 字段 | 值 |
|---|---|
| Authorization URL | https://oauth.kungal.com/api/v1/oauth/authorize |
| Token URL | https://oauth.kungal.com/api/v1/oauth/token |
| Scope | openid profile email |
Fetch User Profile Script:
function fetchUserProfile(accessToken, context, callback) {
request.get(
{
url: 'https://oauth.kungal.com/api/v1/oauth/userinfo',
headers: {
Authorization: 'Bearer ' + accessToken,
Accept: 'application/json'
}
},
function (err, resp, body) {
if (err) return callback(err)
if (resp.statusCode !== 200) {
return callback(new Error('userinfo ' + resp.statusCode + ': ' + body))
}
var p
try {
p = JSON.parse(body)
} catch (e) {
return callback(new Error('userinfo returned non-JSON: ' + body))
}
// 响应就是裸 userinfo 对象,字段直接在顶层 —— 不要写 p.data.xxx
callback(null, {
user_id: p.sub, // OIDC subject(UUID),稳定唯一标识
nickname: p.name,
name: p.name,
picture: p.picture,
email: p.email, // 没申请 email scope 时这个键根本不存在
email_verified: !!p.email,
kungal_id: p.id, // 我们的整数用户 ID,需要就留着
roles: p.roles
})
}
)
}
三个容易踩的点
- 不要写
p.data.access_token/p.data.sub。 协议端点没有信封(2026-07-25 之前有, 现在没有了)。 - 不要合成假邮箱。 没申请
emailscope 时email这个键不存在,不是空串。 写email: p.email || (p.sub + '@你的域')会让你名下全部用户的邮箱变成假地址 —— 这是真实发生过的事故,用户此后无法凭邮箱找回账号。要邮箱就申请emailscope。 Authorization头的 scheme 大小写我们不再敏感(RFC 7235 §2.1),bearer/Bearer都行。但请求头名必须是Authorization。
8. 参考
- 错误码与线格式契约:04-tokens-and-errors.md
- 协议端点说明:01-oauth-endpoints.md
- 规范原文:RFC 6749 §5.1 / §5.2(token 响应与错误)、RFC 6750 §3(Bearer 错误)、 RFC 7009 §2.2(revoke)、RFC 7235 §2.1(scheme 大小写)、OIDC Core §5.3.3(userinfo 错误)、 §12.2(refresh 的 id_token)
源:nextmoe-infra/docs/integration/oauth/13-standard-wire-migration.md