RankHub

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 ⇒ 换令牌 401 invalid_client
  • redirect_uri 与注册值差一个字符 ⇒ 授权页 400 redirect_uri is not registered for this client
  • code 用第二次 ⇒ 400 Invalid or already used authorization code
  • 不带 code_verifier(公开客户端)⇒ 400 code_verifier is required (PKCE S256)
  • code_verifier 不对 ⇒ 400 PKCE 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 / DPoP proof / nonce;
  • 只申请真正需要的 scope(默认 openid profile,按需加)。

本页内容

官方交流群二维码官方交流群问题反馈 · 版本资讯 · 成绩交流群号 1073434398