授权码 + PKCE
标准授权码流程:把用户送到托管同意页、拿回 code、换令牌。含参数表、可复制的代码与全部失败分支。
这是 RankHub 的主流程:Web 应用、SPA、移动端、桌面端都用它。
浏览器打开哪一条授权地址?
第三方应该用托管同意页 https://auth.rankhub.cc/oauth/authorize。
https://auth.rankhub.cc/v1/oauth/authorize 是协议端点:它要求调用方在 auth 域已有登录会话
(没有就 401 Missing session cookie),并且返回的是 { "redirectUrl": "…" } 这样的 JSON,
而不是页面。把它当登录页用会得到一段 JSON。
第一步:把用户送到同意页
GET https://auth.rankhub.cc/oauth/authorize
?client_id=your-client-id
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
&response_type=code
&scope=openid%20profile
&state=<随机串>
&nonce=<随机串>
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256| 参数 | 必填 | 说明 |
|---|---|---|
client_id | 是 | 开发者平台里的 Client ID;应用必须已过审且启用,否则 invalid_client |
redirect_uri | 是 | 必须与注册值逐字一致 |
response_type | 是 | 只接受 code |
scope | 否 | 空格分隔;缺省 openid profile;必须是「默认 scope ∪ 注册 scope」的子集 |
state | 强烈建议 | 原样回写,用来防 CSRF / 对应请求;内嵌模式必填 |
nonce | 建议 | 回写进 id_token 的 nonce,用来防重放 |
code_challenge | 公开客户端必填 | BASE64URL(SHA256(verifier)),不带 padding |
code_challenge_method | 公开客户端必填 | 只能是 S256(plain 不支持) |
prompt=none | 可选 | 静默尝试:有 auth 域会话就直接回码,没有就回 error=login_required。不要依赖它做登录态保持 |
同意页对用户显示:应用名、Logo、主页,以及每条 scope 的中文说明(身份标识 / 基本资料 /
邮箱 / 手机号 / 离线访问 / 游戏资料 …)。用户批准后浏览器被送回你的 redirect_uri。
# 批准
https://app.example.com/auth/callback?code=<一次性授权码>&state=<原样回写>
# 拒绝
https://app.example.com/auth/callback?error=access_denied&state=<原样回写>
# 静默模式且没有可用登录态
https://app.example.com/auth/callback?error=login_required&state=<原样回写>第二步:生成 PKCE 与 state
// 浏览器 / Node 18+ 都可用(Web Crypto)
const b64url = (bytes: Uint8Array) =>
btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
export async function createPkce() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(48))); // 43~128 字符
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = b64url(new Uint8Array(digest));
const state = b64url(crypto.getRandomValues(new Uint8Array(24)));
const nonce = b64url(crypto.getRandomValues(new Uint8Array(24)));
return { verifier, challenge, state, nonce };
}把 verifier / state / nonce 存在发起授权的那一侧(浏览器 sessionStorage、后端会话、
移动端安全存储),回调时用它们做校验。verifier 绝不能出现在授权请求里。
第三步:换令牌
curl -sS https://auth.rankhub.cc/v1/oauth/token \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'code=<回调里的 code>' \
-d 'redirect_uri=https://app.example.com/auth/callback' \
-d 'client_id=your-client-id' \
-d 'code_verifier=<第一步的 verifier>'机密客户端再加认证(二选一,不要同时用):
# 方式 A:HTTP Basic(client_id / client_secret 都要 URL 编码)
curl -sS https://auth.rankhub.cc/v1/oauth/token \
-u 'your-client-id:your-client-secret' \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=…'
# 方式 B:放进 body
curl -sS https://auth.rankhub.cc/v1/oauth/token \
-d 'grant_type=authorization_code&code=…&redirect_uri=…&client_id=…&client_secret=…&code_verifier=…'请求体可以是 application/x-www-form-urlencoded 也可以是 JSON,字段名一样。
成功响应(协议形状,不套 RankHub 的 v1 信封):
{
"access_token": "at_…",
"token_type": "Bearer",
"expires_in": 600,
"scope": "openid profile",
"refresh_token": "rt_…",
"id_token": "eyJhbGciOiJFUzI1NiJ9.…"
}| 字段 | 说明 |
|---|---|
access_token | 不透明串,不是 JWT(at_ 前缀)。不要尝试本地解析 |
token_type | Bearer(未绑定)或 DPoP(绑定了 DPoP 公钥)。按它决定怎么带令牌 |
expires_in | access token 剩余秒数(默认 600)。读它,不要硬编码 |
scope | 实际授予的 scope(空格分隔),可能与请求略有出入 |
refresh_token | 授权码换令牌总会下发(不管有没有 offline_access),每次都轮换 |
id_token | 仅当 scope 含 openid 时出现;JWS,默认 ES256 |
cnf | 只在 token_type=DPoP 时出现:{ "jkt": "<公钥指纹>" } |
令牌能调什么,不只由 scope 决定
游戏数据只能经 /v1 访问,全部在 /v1/games/**(旧的 /v0 游戏面只服务 RankHub 自有客户端)。
授予了 game:write 也不等于能调导入 / 同步 / 任务面 —— 那些形状对第三方一律
403 game.firstPartyOnly。边界见权限范围与可用 API。
浏览器只能走公开客户端
client_secret 一旦进了浏览器就等于泄露。前端(SPA)请注册成 公开客户端(none),
用 PKCE + JS SDK 或自己实现;机密客户端只允许在服务端换令牌。
回调页要做的四件事
校验 state
与发起时保存的 state 严格比较,不一致直接中止(不要继续换令牌)。
处理 error
error=access_denied 是用户拒绝(提示后可重试);error=login_required 只在
prompt=none 静默尝试时出现,属于正常结果,别弹错误框。
换令牌并校验 nonce
拿到 id_token 后校验 iss / aud / exp,以及 nonce 是否回显(见 OIDC)。
立刻把 code 从地址栏去掉
history.replaceState 抹掉 ?code=…,避免被复制分享或进历史记录。
常见失败与原因
| 现象 | 原因 |
|---|---|
| 同意页直接报「应用不可用 / invalid_client」 | 应用没通过审核、被停用,或 client_id 写错 |
授权页 400 redirect_uri is not registered for this client | 回调地址与注册值不完全一致(斜杠、大小写、端口、末尾参数) |
授权页 400 PKCE S256 code_challenge is required | 公开客户端没带 code_challenge,或用了 plain |
换令牌 400 Invalid or already used authorization code | code 用过一次、超过 10 分钟、或换了 redirect_uri |
换令牌 400 redirect_uri mismatch | 换令牌时的 redirect_uri 与授权时不同(必须逐字一致) |
换令牌 400 code is bound to a different DPoP key | 授权时用了 DPoP、换令牌时没带同一把 key 的 proof |
换令牌 401 invalid_client | secret 错、认证方式与注册的不一致、或 Basic 与 body 同时传 |
错误响应恒为两键:{ "error": "…", "error_description": "…" }。完整清单见错误与排障。
下一步
- 令牌怎么保存、怎么刷新、怎么撤销:令牌、刷新与撤销
- 想拿到用户资料:OIDC 与用户身份
- 想知道拿到的令牌能调什么:权限范围与可用 API