错误
错误遵循 RFC 6749 的结构 —— 一个 error 代码,通常还有一个 error_description:
{"error": "invalid_grant", "error_description": "授权码已过期"}
写任何错误处理代码之前,先知道两件事:
error_description是中文,而且不是稳定接口。 请基于error和 HTTP 状态码 分支。描述只用于你自己的日志,永远不要展示给最终用户。- 描述与成因不是一一对应的。 多种不同的失败会共用同一个
error代码。凡是会 造成影响的地方,下文都单独指出。
授权请求
GET /oauth/authorize 会在浏览器里以 JSON 返回这些错误。它们不会被重定向到你的
回调地址,所以撞上的用户会看到一个原始的 JSON 页面 —— 也就是说,这些是你需要在上线
前修掉的对接 bug,而不是运行时需要处理的状态。
| 状态码 | error | error_description | 成因 |
|---|---|---|---|
| 400 | invalid_request | 缺少 client_id 或 redirect_uri 参数 | client_id 或 redirect_uri 缺失或为空 |
| 400 | unsupported_response_type | 仅支持 response_type=code | response_type 存在且不是 code |
| 400 | invalid_client | 应用不存在或已被禁用 | client_id 未知,或应用已被停用 |
| 400 | invalid_redirect_uri | 回调地址未注册 | redirect_uri 与任何已注册地址都不是逐字节一致 |
| 400 | invalid_scope | scope 'X' 不被允许 | X 不在你的允许集合内。X 为空说明你的 scope 字符串里有连续空格、开头空格或结尾空格。 |
校验按上表顺序执行,所以你一次只会看到第一个失败。修好再试。
会送到回调地址的错误
用户到达授权页之后,失败会以查询参数的形式回到你的回调地址,并带上 state。
error | 含义 | 该怎么做 |
|---|---|---|
access_denied | 用户拒绝了 | 把他带回你的应用;不要自动重试 |
login_required | 用了 prompt=none 但用户未登录 | 去掉 prompt=none 重试 |
consent_required | 用了 prompt=none 但没有匹配的授权记录 | 去掉 prompt=none 重试 |
interaction_required | 用了 prompt=none 但授权检查失败 | 去掉 prompt=none 重试 |
Token 端点
POST /oauth/token。
| 状态码 | error | error_description | 成因 |
|---|---|---|---|
| 400 | unsupported_grant_type | — | grant_type 缺失或不是两种受支持的值之一。发 JSON 请求体时收到的也是这个 —— 字段根本没被读取。 |
| 401 | invalid_client | — | client_id 未知,或 client_secret 不对。两者不作区分。 |
| 400 | invalid_grant | 授权码无效或已使用 | 授权码未知、属于别的客户端,或已经被换取过 |
| 400 | invalid_grant | 授权码已过期 | 距签发超过 10 分钟 |
| 400 | invalid_grant | redirect_uri 不匹配 | redirect_uri 与发给 /oauth/authorize 的不一致 |
| 400 | invalid_grant | 需要 code_verifier | 授权码是走 PKCE 签发的,但没传 code_verifier |
| 400 | invalid_grant | code_verifier 验证失败 | verifier 与 challenge 不匹配 —— 常见原因是 code_challenge_method 拼错 |
| 400 | invalid_grant | 刷新令牌无效 | refresh token 未知,或它是签发给另一个客户端的 |
| 400 | invalid_grant | 刷新令牌已被使用,所有令牌已撤销 | 提交了一个已经轮换过的 refresh token —— 见下文 |
| 400 | invalid_grant | 刷新令牌已过期 | 超出了自首次授权起算的 30 天窗口 |
密钥明明是对的却收到 invalid_client
几乎所有情况都出自这两个原因:
=没有做百分号编码。 client secret 和令牌都以=结尾。如果你是自己拼字符串 而不是把参数字典交给 HTTP 库,取值被截断了。- 你用了 HTTP Basic。 凭证要放在表单请求体里。
client_secret_basic不受支持, 而且不会给出可区分的错误。
刷新令牌已被使用,所有令牌已撤销
这是重放检测。提交一个已经轮换过的 refresh token 会被当作重放,响应会撤销该用户在该 应用下的所有 access token 和 refresh token。
有三条路会走到这里:
- 你从响应里拿到了新的 refresh token,但进程在写入完成前就挂了,于是重放了旧的那个。
- 两个 worker 并发刷新。请按用户串行化刷新。
- 用户在 Nanako 账号设置里撤销了你的应用,令牌被标记为已撤销;你下一次诚实的刷新 于是看起来像一次重放。
三种情况的恢复方式相同:丢弃已存的令牌,让用户重新走一遍授权流程。不要重试。
UserInfo
GET /oauth/userinfo。
| 状态码 | error | error_description | 成因 |
|---|---|---|---|
| 401 | invalid_token | — | 没有 Authorization 头,或它不是以 Bearer 开头 |
| 401 | invalid_token | — | 令牌未知或已被撤销 |
| 401 | invalid_token | token 已过期 | 超过 2 小时有效期 |
| 500 | server_error | — | 令牌背后的账号已经不存在 |
对两种没有描述的 401,刷新后重试一次。如果刷新也失败,重新走授权。
撤销
POST /oauth/revoke。
| 状态码 | error | 成因 |
|---|---|---|
| 400 | invalid_request | client_id 或 token 缺失或为空 —— 包括请求体是 JSON 的情况 |
| 401 | invalid_client | client_id 未知或 client_secret 不对 |
| 200 | — | 其余全部情况,包括令牌不存在、不属于你、或没带必需的 token_type_hint |
200 不代表撤销发生了。见 POST /oauth/revoke。
不是来自 OAuth 的错误
有两种响应来自周边基础设施,而不是 OAuth 处理逻辑:
| 状态码 | 响应体 | 含义 |
|---|---|---|
| 503 | {"error":"feature_disabled","feature":"developer"} | 运营方关闭了开发者子系统,响应带 Retry-After: 300。受影响的是应用管理和账号界面里的已授权应用列表;登录流程本身不受影响。 |
| 413 | — | 请求体超过 110 MB。在 OAuth 端点上你不应该见到它。 |
一份可用的重试策略
| 响应 | 是否重试 |
|---|---|
userinfo 返回 401 invalid_token | 是 —— 先刷新一次,再重试 |
刷新时返回 invalid_grant | 否 —— 重新授权 |
换取授权码时返回 invalid_grant | 否 —— 授权码已作废,从 /oauth/authorize 重新开始 |
invalid_client | 否 —— 这是配置错误 |
503 feature_disabled | 是 —— 等过 Retry-After |
5xx | 是 —— 退避重试,且只对幂等的读操作 |
永远不要用同一个授权码重试换取。授权码只能用一次,无论第一次是否成功,第二次都会失败。