API 目前处于 Early Preview 阶段, 可能存在诸多不完善与错误之处,欢迎加群交流反馈:加入官方交流群(1073434398)
端到端示例
三个可直接照抄的最小实现:纯前端 SPA、Node 服务端、curl 手工走查,以及上线前自检清单。
A. 纯前端 SPA(公开客户端 + PKCE)
// ---- ① 点「登录」 ----
const CLIENT_ID = 'your-client-id';
const REDIRECT_URI = 'https://app.example.com/auth/callback';
const AUTH = 'https://auth.rankhub.cc';
const b64url = (b: Uint8Array) =>
btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
function random(n: number) {
return b64url(crypto.getRandomValues(new Uint8Array(n)));
}
export async function login() {
const verifier = random(48);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const state = random(24);
const nonce = random(24);
sessionStorage.setItem('rh.pkce', JSON.stringify({ verifier, state, nonce }));
const url = new URL(`${AUTH}/oauth/authorize`);
url.search = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid profile',
state,
nonce,
code_challenge: b64url(new Uint8Array(digest)),
code_challenge_method: 'S256',
}).toString();
location.assign(url.toString());
}
// ---- ② 回调页 ----
export async function handleCallback() {
const saved = JSON.parse(sessionStorage.getItem('rh.pkce') || 'null');
const params = new URLSearchParams(location.search);
history.replaceState({}, '', '/'); // 先把 code 从地址栏抹掉
if (!saved) throw new Error('登录请求已过期');
if (params.get('state') !== saved.state) throw new Error('state 不匹配');
if (params.has('error')) throw new Error(params.get('error')!);
const res = await fetch(`${AUTH}/v1/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code: params.get('code')!,
code_verifier: saved.verifier,
}),
});
const token = await res.json();
if (!res.ok) throw new Error(token.error_description || token.error);
sessionStorage.removeItem('rh.pkce');
return token; // { access_token, token_type, expires_in, scope, refresh_token, id_token }
}拿到令牌后,id_token 的校验见 OIDC 与用户身份(浏览器里请用 jose 之类的库 +
远端 JWKS,不要自己拼验签)。
B. Node 服务端(机密客户端)
import crypto from 'node:crypto';
const CLIENT_ID = process.env.RH_CLIENT_ID!;
const CLIENT_SECRET = process.env.RH_CLIENT_SECRET!;
const REDIRECT_URI = 'https://app.example.com/auth/callback';
const AUTH = 'https://auth.rankhub.cc';
const basic = 'Basic ' + Buffer.from(
`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`,
).toString('base64');
export function authorizeUrl(state: string, verifier: string, nonce: string) {
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
const url = new URL(`${AUTH}/oauth/authorize`);
url.search = new URLSearchParams({
client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code',
scope: 'openid profile', state, nonce,
code_challenge: challenge, code_challenge_method: 'S256',
}).toString();
return url.toString();
}
export async function exchange(code: string, verifier: string) {
const res = await fetch(`${AUTH}/v1/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: basic },
body: new URLSearchParams({
grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, code_verifier: verifier,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error}: ${data.error_description}`);
return data;
}
// ❗ 每用户一把锁:并发刷新会被判成重放,整族撤销
const locks = new Map<string, Promise<any>>();
export function refresh(userId: string, refreshToken: string) {
const running = locks.get(userId);
if (running) return running;
const task = (async () => {
const res = await fetch(`${AUTH}/v1/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: basic },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error}: ${data.error_description}`);
return data; // 必须把新的 refresh_token 写回存储
})().finally(() => locks.delete(userId));
locks.set(userId, task);
return task;
}C. curl 手工走一遍
# 1) 打开下面的地址(把 client_id / redirect_uri 换成你自己的),在 App 里批准
echo '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=test&code_challenge=REPLACE_ME&code_challenge_method=S256'
# 2) 从回调 URL 里取出 code(一次性,10 分钟内有效)
CODE='...'
# 3) 换令牌
curl -sS https://auth.rankhub.cc/v1/oauth/token \
-d 'grant_type=authorization_code' \
-d "code=$CODE" \
-d 'client_id=your-client-id' \
-d 'redirect_uri=https://app.example.com/auth/callback'
# 4) 用 access_token 调 userinfo
curl -sS https://auth.rankhub.cc/v1/oidc/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 5) 调业务接口(需要对应 scope;游戏数据在 /v1/games/**)
curl -sS https://api.rankhub.cc/v1/games/maimai/profiles \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 6) 收尾:撤销
curl -sS -X POST https://auth.rankhub.cc/v1/oauth/revoke -d "token=$REFRESH_TOKEN"第 1 步的 code_challenge 必须是真实值(S256(verifier)),否则公开客户端会被拒;
机密客户端如果没开 PKCE 才能省掉它。
用错参数会看到什么
- 换成不存在的
client_id⇒ 换令牌 401invalid_client redirect_uri与注册值差一个字符 ⇒ 授权页 400redirect_uri is not registered for this client- code 用第二次 ⇒ 400
Invalid or already used authorization code - 不带
code_verifier(公开客户端)⇒ 400code_verifier is required (PKCE S256) code_verifier不对 ⇒ 400PKCE verification failed
D. 上线前自检
- 应用在开发者平台已过审且启用;
client_id/ secret 从环境变量读,不进代码库; - 回调地址在平台里逐字登记,且与换令牌时传的完全一致;
- 授权请求带
state(并校验)、带nonce(并校验 id_token 回显); - 公开客户端强制
S256,verifier 只在本地保存; - 浏览器里没有
client_secret;机密客户端的 secret 只在服务端; - 用响应里的
expires_in算过期时间,没硬编码 600; - 刷新串行化、保存新的 refresh token、遇到
invalid_grant走"重新授权"而不是死循环重试; - 401 才清会话,503 可重试;
-
id_token校验算法白名单 /iss/aud/exp/nonce; - userinfo 显式判
error字段(它失败也回 200); - 用户登出/卸载时调
/v1/oauth/revoke; - 日志里没有
at_/rt_/dc_/code/DPoPproof /nonce; - 只申请真正需要的 scope(默认
openid profile,按需加)。