GitHub

13. 协议端点线格式标准化迁移指南(第三方必读)

一句话/oauth/token/oauth/userinfo/oauth/revoke 三个端点的响应,将从我们自家的 {code, message, data} 信封改为 RFC 6749 / RFC 6750 标准裸 JSON。如果你的代码里写过 res.data.access_tokenif (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 顺带修好的三件事(不需要你改,但值得知道)

  1. Authorization 头的 scheme 现在大小写不敏感(RFC 7235 §2.1)。以前 bearer xxx(小写)会被拒, 而且报的是「令牌无效」——如果你曾经因为这个查过问题,就是它。
  2. 所有 401/403 现在都带 WWW-Authenticate,你可以据此区分「token 该刷新了」和「请求写错了」。
  3. 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. 三个必须做对的判定(这是实际会出事的地方)

我们自家站点在这次迁移里踩过,列出来供你对照:

  1. invalid_token 必须被识别成「凭据已死」。 如果你的映射表里漏了它,它会落到「未知错误 → 瞬态 → 重试」分支,结果是抱着永久失效的 token 无限重试,永远不重新认证
  2. 只有未知错误和 5xx 才算瞬态。 invalid_grant / invalid_client / unauthorized_client / invalid_token 一律清会话、要求重新登录。
  3. 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
      })
    }
  )
}

三个容易踩的点

  1. 不要写 p.data.access_token / p.data.sub 协议端点没有信封(2026-07-25 之前有, 现在没有了)。
  2. 不要合成假邮箱。 没申请 email scope 时 email 这个键不存在,不是空串。 写 email: p.email || (p.sub + '@你的域') 会让你名下全部用户的邮箱变成假地址 —— 这是真实发生过的事故,用户此后无法凭邮箱找回账号。要邮箱就申请 email scope。
  3. 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