Skip to content

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(需 unionid scope)

实现说明

自包含实现:授权码 / 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)
    ◄──────────────────────────────┤

前置准备

  1. 在开发者中心创建 miniApp,获得 AppKey(即 client_id)与 AppSecret(即 client_secret)。
  2. 登记 redirect_uris 回跳地址白名单(推荐,精确匹配)。登记后支持任意 scheme:native 客户端可用 custom scheme(如 oauthdemo://callback)或 loopback(http://127.0.0.1:port)。
  3. 未登记白名单时的回退规则:
    • 配置了「网站域名」→ 回跳地址必须是绝对 http(s) 且与网站同域或子域
    • 两者都未配置 → 仅开发期放行绝对 http(s) 地址(生产环境应强制登记白名单)
  4. 可选:登记允许的 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_idminiApp 的 AppKey
redirect_uri回跳地址,须与登记一致
scope空格分隔,默认 openid
state推荐随机串,回跳时原样返回,防 CSRF
code_challengePKCE 挑战值:base64url(sha256(code_verifier))
code_challenge_method固定 S256(必须使用 PKCE)
nonceOIDC 随机串,会写入 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_typeauthorization_code
code回跳拿到的授权码
redirect_uri必须与授权请求时完全一致
client_idAppKey
client_secret机密客户端必填AppSecret(公开客户端 AppType=1 可不传)
code_verifierPKCE 原始随机串,服务端校验 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_typerefresh_token
refresh_token之前颁发的 refresh_token
client_idAppKey
client_secret机密客户端必填AppSecret

refresh_token 轮换

每次刷新都会作废旧的 refresh_token 并颁发新的,请保存响应中的新 refresh_token。

令牌有效期

令牌有效期
授权码 code15 分钟(单次使用)
access_token8 小时(28800 秒)
refresh_token30 天(轮换制)
id_token8 小时

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
unionIdunionid跨应用唯一标识
name / nicknameprofile昵称
pictureprofile头像 URL
genderprofile性别:0-未知 1-男 2-女
mobile / phone_numbermobile手机号 / 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://..."
}

验签要点:

  1. 用 AppSecret 按 HS256 验证签名
  2. 校验 aud == 你的 AppKey
  3. 校验 exp 未过期
  4. 若请求时传了 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": "授权码无效或已过期" }
HTTPerror常见原因
400invalid_request缺少 PKCE 参数 / 未使用 S256
400unsupported_response_typeresponse_type 不是 code
400unsupported_grant_typegrant_type 不是 authorization_code / refresh_token
401invalid_clientclient_id 不存在或已禁用 / client_secret 错误
400invalid_grant授权码无效或已过期 / 与 client 不匹配 / redirect_uri 不一致 / PKCE 校验失败 / refresh_token 无效
401invalid_tokenuserinfo 缺少或无效的 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。