外观
OAuth 2.0 第三方授权登录
概述
Kango 平台可作为身份提供者(IdP),对外提供标准 OAuth 2.0 授权码 + PKCE 登录。第三方网站 / App 可以用「Kango 账号登录」,获取用户的 openId 与公开资料。
三个角色:
| 角色 | 对应 |
|---|---|
| 资源拥有者 | 平台用户 |
| 授权 + 资源服务器 | Kango 平台(apisvc) |
| 客户端 | 第三方网站 / App(注册为 miniApp,client_id = AppKey,client_secret = AppSecret) |
身份选型(对标微信开放平台):
sub= openId:按 miniApp 配对生成,同一用户在不同应用中 openId 不同,第三方之间不可撞库- unionId:企业级唯一标识,同一开发者(企业)名下多个 App 共享,仅作为附加 claim(需
unionidscope)
实现说明
自包含实现:授权码 / access_token / refresh_token 存 Redis;id_token 为 JWT,用该 client 的 AppSecret 以 HS256 签名(第三方用自己的 secret 即可验签)。端点形状与 Ory Hydra 对齐。
授权流程
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)
◄──────────────────────────────┤前置准备
- 在开发者中心创建 miniApp,获得
AppKey(即client_id)与AppSecret(即client_secret)。 - 登记 redirect_uris 回跳地址白名单(推荐,精确匹配)。登记后支持任意 scheme:native 客户端可用 custom scheme(如
oauthdemo://callback)或 loopback(http://127.0.0.1:port)。 - 未登记白名单时的回退规则:
- 配置了「网站域名」→ 回跳地址必须是绝对 http(s) 且与网站同域或子域
- 两者都未配置 → 仅开发期放行绝对 http(s) 地址(生产环境应强制登记白名单)
- 可选:登记允许的
scopes范围。为空表示不限制;openid始终允许。
客户端类型
- 机密客户端(默认):换 token 时必须校验
client_secret,secret 只能放服务端。 - 公开客户端(native App / SPA,AppType=1):无法安全保管 secret,仅靠 PKCE 校验,换 token 时不需要
client_secret。
scope 说明
| scope | 说明 |
|---|---|
openid | 确认用户身份(openId),始终允许;携带时返回 id_token |
profile | 获取昵称与头像 |
mobile | 获取手机号 |
unionid | 获取 unionId(跨应用唯一标识) |
多个 scope 用空格分隔,如 openid profile。请求的 scope 会被收敛到 client 已登记的范围内。
端点
1. 授权端点
http
GET /api/oauth/authorize浏览器跳转入口(公开)。校验通过后 302 跳转到用户同意页。
| 参数 | 必填 | 说明 |
|---|---|---|
response_type | 是 | 固定 code |
client_id | 是 | miniApp 的 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 后立即作废。
2. 令牌端点
http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded机器对机器调用(公开)。client 凭证支持两种传法:HTTP Basic Auth 或表单字段。
grant_type=authorization_code(授权码换 token)
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | authorization_code |
code | 是 | 回跳拿到的授权码 |
redirect_uri | 是 | 必须与授权请求时完全一致 |
client_id | 是 | AppKey |
client_secret | 机密客户端必填 | AppSecret(公开客户端 AppType=1 可不传) |
code_verifier | 是 | PKCE 原始随机串,服务端校验 base64url(sha256(verifier)) == challenge |
响应(OAuth 标准顶层 JSON,非业务包装):
json
{
"access_token": "xxxxxxxx",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "yyyyyyyy",
"scope": "openid profile",
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}id_token 仅当 scope 含 openid 时返回。
grant_type=refresh_token(刷新令牌)
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | refresh_token |
refresh_token | 是 | 之前颁发的 refresh_token |
client_id | 是 | AppKey |
client_secret | 机密客户端必填 | AppSecret |
refresh_token 轮换
每次刷新都会作废旧的 refresh_token 并颁发新的,请保存响应中的新 refresh_token。
令牌有效期
| 令牌 | 有效期 |
|---|---|
| 授权码 code | 15 分钟(单次使用) |
| access_token | 8 小时(28800 秒) |
| refresh_token | 30 天(轮换制) |
| id_token | 8 小时 |
3. UserInfo 端点
http
GET /api/oauth/userinfo
Authorization: Bearer {access_token}返回原始 OIDC claims(按 scope 过滤):
json
{
"sub": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"unionId": "a1b2c3d4...",
"name": "张三",
"nickname": "张三",
"picture": "https://cdn.example.com/avatar.jpg",
"gender": 1,
"mobile": "13800138000",
"phone_number": "+8613800138000"
}| claim | 所需 scope | 说明 |
|---|---|---|
sub | 始终返回 | 用户 openId |
unionId | unionid | 跨应用唯一标识 |
name / nickname | profile | 昵称 |
picture | profile | 头像 URL |
gender | profile | 性别:0-未知 1-男 2-女 |
mobile / phone_number | mobile | 手机号 / E.164 格式手机号 |
id_token 验签
id_token 为 JWT,HS256 签名,密钥 = 你的 AppSecret,第三方服务端可直接本地验签,无需回调平台。
Payload 示例:
json
{
"iss": "https://kg.citv.cc",
"sub": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"aud": "YOUR_APP_KEY",
"iat": 1780000000,
"exp": 1780028800,
"nonce": "abc123",
"unionId": "a1b2c3d4...",
"name": "张三",
"picture": "https://..."
}验签要点:
- 用 AppSecret 按 HS256 验证签名
- 校验
aud== 你的 AppKey - 校验
exp未过期 - 若请求时传了
nonce,校验一致(防重放)
PKCE 前端示例(JS)
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",
client_secret: "YOUR_APP_SECRET", // 公开客户端(AppType=1)不传,机密客户端建议放服务端换取
code_verifier: flow.verifier
});
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(服务端强制要求)。
错误响应
令牌端点错误为 OAuth 标准格式:
json
{ "error": "invalid_grant", "error_description": "授权码无效或已过期" }| HTTP | error | 常见原因 |
|---|---|---|
| 400 | invalid_request | 缺少 PKCE 参数 / 未使用 S256 |
| 400 | unsupported_response_type | response_type 不是 code |
| 400 | unsupported_grant_type | grant_type 不是 authorization_code / refresh_token |
| 401 | invalid_client | client_id 不存在或已禁用 / client_secret 错误 |
| 400 | invalid_grant | 授权码无效或已过期 / 与 client 不匹配 / redirect_uri 不一致 / PKCE 校验失败 / refresh_token 无效 |
| 401 | invalid_token | userinfo 缺少或无效的 Bearer access_token |
| — | access_denied | 用户在同意页拒绝授权(回跳到 redirect_uri) |
在线测试页
平台内置了模拟第三方网站的测试页(授权码 + PKCE 全流程):
text
http://127.0.0.1:9050/jssdk/oauth-demo.html填入 client_id(AppKey)、client_secret(AppSecret)、redirect_uri(默认为本页地址,须与登记一致)与 scope,点「开始授权登录」即可走完整流程,页面会展示授权码、token 响应、id_token 解码结果与 userinfo。