外观
授权页跳转登录
第三方网站 / App 把浏览器跳到 Kango 授权页,用户在授权页登录 Kango 账号并点「同意」,Kango 带授权码 code 回跳你的 redirect_uri。适用于网站、App、SPA。
拿到 code 之后的换 token、拉用户信息、验 id_token,以及前置准备、scope、错误码,见 OAuth 2.0 授权登录。PC 网站想内嵌二维码让用户用手机扫,见 网站扫码登录。
测试页
apisvc/jssdk/oauth-demo.html 是一个纯前端测试页(AppSecret 填在网页里,只为一个文件跑通)。正式接入请参照 服务端完整示例,示例里的「跳转 Kango 授权页登录」就是本页的流程。
流程
text
第三方网站 Kango 平台
│
│ 1. 跳转 GET /api/oauth/authorize
│ (client_id, redirect_uri, scope, state,
│ code_challenge, code_challenge_method=S256)
├──────────────────────────────►
│ 2. 校验 client / redirect_uri / PKCE
│ 3. 302 跳转到用户同意页(登录 + 同意授权)
│ 4. 用户点「同意」→ 签发授权码 code
│ 5. 302 回跳 redirect_uri?code=xxx&state=xxx
◄──────────────────────────────┤
│
│ 6. POST /api/oauth/token(服务端 / 前端均可)
│ (grant_type=authorization_code, code,
│ redirect_uri, client_id, client_secret, code_verifier)
├──────────────────────────────►
│ 7. 返回 access_token + refresh_token + id_token
◄──────────────────────────────┤
│
│ 8. GET /api/oauth/userinfo (Bearer access_token)
├──────────────────────────────►
│ 9. 返回用户 claims(sub=openId)
◄──────────────────────────────┤第 6 ~ 9 步见 令牌端点、UserInfo 端点。
授权端点
http
GET /api/oauth/authorize浏览器跳转入口(公开)。校验通过后 302 跳转到用户同意页。
| 参数 | 必填 | 说明 |
|---|---|---|
response_type | 是 | 固定 code |
client_id | 是 | H5APP 的 AppKey |
redirect_uri | 是 | 回跳地址,须与登记一致 |
scope | 否 | 空格分隔,默认 openid |
state | 推荐 | 随机串,回跳时原样返回,防 CSRF |
code_challenge | 是 | PKCE 挑战值:base64url(sha256(code_verifier)) |
code_challenge_method | 是 | 固定 S256(必须使用 PKCE) |
nonce | 否 | OIDC 随机串,会写入 id_token |
用户同意后回跳:
text
{redirect_uri}?code=授权码&state=原样返回用户拒绝或出错回跳:
text
{redirect_uri}?error=access_denied&error_description=用户拒绝授权&state=...INFO
授权码有效期 15 分钟,单次使用,换 token 后立即作废。
必须使用 PKCE
本方式下机密客户端和公开客户端都必须带 code_challenge(S256),换 token 时带对应的 code_verifier。机密客户端另外还要带 client_secret。
PKCE 前端示例(JS)
适用于公开客户端(SPA / native App,AppType=1)。机密客户端请在服务端生成 code_verifier 并完成换 token,见 服务端完整示例。
公开客户端的令牌
公开客户端刷新不需要 secret,refresh_token 被偷就能直接续,所以平台对它收紧了:
js
// 生成 code_verifier(随机串)与 code_challenge(S256)
function randHex(n) {
const a = new Uint8Array(n);
crypto.getRandomValues(a);
return Array.from(a, (b) => ("0" + b.toString(16)).slice(-2)).join("");
}
function b64url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
async function challengeOf(verifier) {
const d = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return b64url(d);
}
// 第 1 步:发起授权
async function startAuthorize() {
const verifier = randHex(48); // code_verifier
const challenge = await challengeOf(verifier); // code_challenge
const state = randHex(8);
// 暂存 verifier / state,回调时使用
sessionStorage.setItem("oauth_flow", JSON.stringify({ verifier, state }));
const p = new URLSearchParams({
response_type: "code",
client_id: "YOUR_APP_KEY",
redirect_uri: "https://your-site.com/callback",
scope: "openid profile",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
location.href = "https://kg.citv.cc/api/oauth/authorize?" + p.toString();
}
// 第 2 步:回调页,code 换 token
async function handleCallback() {
const q = new URLSearchParams(location.search);
const code = q.get("code");
const flow = JSON.parse(sessionStorage.getItem("oauth_flow") || "{}");
// 校验 state,防 CSRF
if (q.get("state") !== flow.state) throw new Error("state 不匹配");
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://your-site.com/callback",
client_id: "YOUR_APP_KEY",
code_verifier: flow.verifier,
// 公开客户端(AppType=1)不传 client_secret;机密客户端不要走这里,改在服务端换取
});
const r = await fetch("https://kg.citv.cc/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
const tok = await r.json();
// 第 3 步:拉用户信息
const r2 = await fetch("https://kg.citv.cc/api/oauth/userinfo", {
headers: { Authorization: "Bearer " + tok.access_token },
});
const userinfo = await r2.json();
console.log("openId:", userinfo.sub);
}安全提示
- 机密客户端的
client_secret只能放服务端,「code 换 token」应由你的后端完成,前端只回传 code。 - 必须校验
state,防 CSRF。 - 本方式必须使用 PKCE S256(服务端强制要求)。