第三方对接指南

5 步完成 OAuth 2.1 + PKCE 对接,附可运行完整 demo。

统一接口标准:云集生态的官方标准 = OAuth 2.1 + OIDC(覆盖网站/APP/小程序,家族产品互联唯一固定协议)。本文是标准对接的实操指南。完整对比见 对接方式总览

流程一览

你的应用                    云集开放生态                 用户
  │                            │                        │
  │── 1. 构造授权 URL ────────→│                        │
  │   (含 PKCE challenge)      │                        │
  │                            │── 2. 跳转授权页 ───────→│
  │                            │←── 3. 用户扫码/授权 ────│
  │←── 4. 带 code 回调 ────────│                        │
  │── 5. code + verifier 换 token →│                    │
  │←── 返回 access_token ──────│                        │
  │                            │                        │
  └─ 登录完成,用 access_token 调 userinfo

准备材料

um.yunjii.cn/console/apps 创建应用,获得:

材料说明示例
appid应用 ID(即 OAuth client_id)1001
appkey应用密钥(即 client_secret,切勿泄露a3f8b2c9d1e7...
回调域名你的回调地址域名yourapp.com

接入域名:um 与 open 等价

OAuth 端点在两个域名上同时可用、完全等价,按品牌需要任选(本文统一写 um.yunjii.cn):

域名定位Discovery
um.yunjii.cn身份服务主站https://um.yunjii.cn/.well-known/openid-configuration
open.yunjii.cn开放平台品牌化入口https://open.yunjii.cn/.well-known/openid-configuration

Discovery 按访问域名自适应返回 issuer 与全部端点:在哪个域名读 discovery,返回的端点就是哪个域名。用通用 OAuth 库时,issuer 填哪个域名,redirect_uri 就用哪个域名的回调,保持一致即可。


5 步对接(OAuth 2.1 + PKCE)

构造 OAuth 2.1 授权 URL,引导用户跳转到 UM 授权页。公开客户端(SPA/小程序)必须启用 PKCE

手动构造

GET https://um.yunjii.cn/oauth/authorize
参数必填说明
response_type固定 code
client_id应用 ID
redirect_uri回调地址(域名需在控制台配置的 domains 中)
state防 CSRF,随机字符串,原样返回
scopeopenid profile(默认)
code_challengePKCE challenge(推荐启用)
code_challenge_methodS256

PHP SDK 一行生成

$state    = bin2hex(random_bytes(16));
$verifier = $um->generateCodeVerifier();           // PKCE
$authUrl  = $um->authorizeUrl($state, $verifier);  // 自动算 challenge
header("Location: $authUrl");

JS SDK

const verifier = UM.generateCodeVerifier();
const challenge = await UM.generateCodeChallenge(verifier);
sessionStorage.setItem('um_pkce', verifier);
location.href = UM.authorizeUrl(state, challenge);

PKCE 流程:客户端生成 code_verifier(随机串)→ 计算 code_challenge = base64url(SHA256(verifier)) → 授权请求带 challenge → 换 token 时带 verifier → 服务端校验匹配。防止授权码被中间人劫持。

用户在 UM 授权页完成扫码(微信/支付宝)或登录(邮箱/密码)。

这一步你不需要做任何事,UM 会自动处理。

用户授权后,UM 会带 codestate 跳转回你的 redirect_uri

https://yourapp.com/callback?code=CODE_xxx&state=你传的state

你需要:

  1. 验证 state 与第一步生成的是否一致(防 CSRF)
  2. 取出 code,进入下一步

code 5 分钟内只能用一次,过期或重复消费返回 errcode=40163

接口POST https://um.yunjii.cn/oauth/token.php

参数

参数必填说明
grant_typeauthorization_code
code上一步拿到的 code
client_id应用 ID
client_secret应用密钥(appkey)
redirect_uri必须与授权时一致
code_verifierPKCE verifier(启用 PKCE 时必传)

响应

{
  "code": 0,
  "msg": "succ",
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 604800,
  "refresh_token": "rt_a1b2c3d4e5f6...",
  "user": {
    "id": 12345,
    "openid": "um_a1b2c3d4e5f6",
    "nickname": "张三",
    "avatar": "https://thirdwx.qlogo.cn/...",
    "login_type": "wx"
  }
}

字段说明

字段说明
access_token访问令牌(7 天有效),后续 API 调用凭证
refresh_token刷新令牌(30 天,轮换制)
user.openid应用内用户唯一 ID(同应用不变)
user.id用户内部 ID
user.login_type登录方式(wx / alipay / qq / douyin / email / self)

OAuth 2.1 标准端点用 code: 0 表示成功(旧版 REST 用 code: 1)。迁移时注意判断条件。

接口GET https://um.yunjii.cn/oauth/userinfo

Authorization: Bearer <access_token>

响应(标准 OIDC claims):

{
  "sub": "um_a1b2c3d4e5f6",
  "name": "张三",
  "nickname": "张三",
  "picture": "https://thirdwx.qlogo.cn/...",
  "email": "zhangsan@example.com",
  "email_verified": true,
  "login_type": "wx",
  "iss": "https://um.yunjii.cn"
}
字段说明
subUMID,跨应用唯一(同一用户在不同应用 UMID 相同)
name / nickname用户资料
picture头像 URL
email邮箱(可能为空)
login_type登录方式

Token 续期(Refresh Token 轮换)

access_token 过期后,用 refresh_token 换取新的 token:

接口POST https://um.yunjii.cn/oauth/token.php

参数必填说明
grant_typerefresh_token
refresh_token上一次获取的 refresh_token
client_id应用 ID
client_secret应用密钥

轮换制:每次刷新签发新的 refresh_token,旧的立即失效。客户端必须用最新的 refresh_token 进行下次刷新。


用户中心 / 个人资料页:载入登录态

登录完成后,用户中心、个人资料等页面加载登录态只有两种正确姿势,选一种即可。

模式 A:服务端 Session(推荐)

token 只存在服务端(session / httpOnly cookie),浏览器永远接触不到。页面渲染时服务端已带登录态:无闪烁、无额外前端请求、token 过期可在服务端静默续期。

Next.js(App Router)——用户中心页直接读 cookie:

// app/(user)/me/page.tsx  (Server Component)
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function MePage() {
  const jar = await cookies();
  const raw = jar.get('um_user')?.value; // 登录回调时写入的用户 JSON

  if (!raw) redirect('/api/auth/um/login'); // 未登录 → 一键去登录

  const u = JSON.parse(raw);
  return (
    <div>
      <img src={u.picture} width={64} alt="" />
      <h1>{u.nickname}</h1>
      <p>UMID:{u.sub}</p>
      <a href="/api/auth/um/logout">退出登录</a>
    </div>
  );
}

PHP——任意受保护页面头部加:

<?php
session_start();
if (empty($_SESSION['user'])) {
    header('Location: /callback.php?act=login'); // 跳登录入口
    exit;
}
$u = $_SESSION['user'];
?>
<img src="<?= htmlspecialchars($u['picture']) ?>" width="64">
<h1><?= htmlspecialchars($u['nickname']) ?></h1>
<p>UMID:<?= htmlspecialchars($u['sub']) ?></p>

模式 B:前端轻量载入(/api/me 代理)

纯 SPA 前端时,提供一个小端点代理 userinfo(token 不出服务端):

// app/api/me/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  const token = req.cookies.get('um_at')?.value;
  if (!token) return NextResponse.json({ logged_in: false });

  const r = await fetch('https://um.yunjii.cn/oauth/userinfo', {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  });
  if (r.status === 401) {
    return NextResponse.json({ logged_in: false, reason: 'token_expired' });
    // 前端收到后跳登录入口;或先走服务端 refresh 再重试(见下)
  }
  return NextResponse.json({ logged_in: true, user: await r.json() });
}

前端 fetch('/api/me'){logged_in, user} → 渲染头像昵称。

不要access_token 下发到浏览器 localStorage——过期刷新、注销、XSS 风险全都难处理。前端只应该拿到"用户资料 JSON"。

Token 过期与静默续期

  • access_token 7 天有效。服务端收到 UM 侧 401 时,用 refresh_token 静默续期一次并重放原请求
  • 续期是轮换制:立即用响应里的新 refresh_token 覆盖旧值。
  • refresh_token 也失效(30 天)→ 清登录态,下次访问重新授权。切勿对 401 死循环刷新。

登出

// app/api/auth/um/logout/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function GET() {
  const jar = await cookies();
  ['um_at', 'um_rt', 'um_user'].forEach((n) => jar.delete(n));
  return NextResponse.redirect('https://um.yunjii.cn/oauth/end_session');
}

跳转 end_session 会同时清除 UM 侧联合会话,家族产品一起登出;只清本地 cookie 则用户在其它云集应用仍是登录态。

UMID 落库最佳实践

userinfo.sub(UMID)为唯一键 upsert 你的 users 表,不要自建映射表

ALTER TABLE users ADD COLUMN um_sub TEXT UNIQUE;
-- 登录回调时(参数化 SQL):
INSERT INTO users (um_sub, nickname, avatar, last_login_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(um_sub) DO UPDATE
  SET nickname = excluded.nickname, avatar = excluded.avatar, last_login_at = excluded.last_login_at;

同一用户在生态内任何应用 UMID 相同——未来打通家族互通(资料同步、积分互认)零迁移成本。


完整 PHP Demo

<?php
require_once 'UM.class.php';

session_start();

$um = new UM(
    '你的_appid',
    '你的_appkey',
    'https://yourapp.com/callback.php',
    'https://um.yunjii.cn/'
);

$act = $_GET['act'] ?? '';

if ($act === 'login') {
    // 1. 生成授权 URL + PKCE
    $state    = bin2hex(random_bytes(16));
    $verifier = $um->generateCodeVerifier();
    $_SESSION['oauth_state']    = $state;
    $_SESSION['oauth_verifier'] = $verifier;

    header('Location: ' . $um->authorizeUrl($state, $verifier));
    exit;
}

if (isset($_GET['code'])) {
    // 3-4. 回调:校验 state + 换 token
    if ($_GET['state'] !== $_SESSION['oauth_state']) {
        die('State 校验失败');
    }

    $tokenRes = $um->token($_GET['code'], $_SESSION['oauth_verifier']);
    if ($tokenRes['code'] !== 0) {
        die('换取 token 失败:' . $tokenRes['msg']);
    }

    // 5. 获取用户信息
    $user = $um->userinfo($tokenRes['access_token']);

    // 清理 PKCE 临时数据
    unset($_SESSION['oauth_state'], $_SESSION['oauth_verifier']);

    // 存储登录态
    $_SESSION['access_token']  = $tokenRes['access_token'];
    $_SESSION['refresh_token'] = $tokenRes['refresh_token'];
    $_SESSION['user']          = $user;

    echo "登录成功!<br>";
    echo "昵称:{$user['nickname']}<br>";
    echo "UMID:{$user['sub']}<br>";
    echo "头像:<img src='{$user['picture']}' width=64><br>";
}

Next.js(App Router)完整 Demo

零依赖(原生 fetch + Web Crypto),三个文件跑通:登录入口 → 回调换 token → 用户中心。

// app/api/auth/um/login/route.ts —— 登录入口:生成 PKCE + 跳转授权页
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const UM = 'https://um.yunjii.cn';
const REDIRECT_URI = 'https://yourapp.com/api/auth/um/callback';

export async function GET() {
  const state = crypto.randomUUID();
  const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
  const challenge = Buffer.from(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
  ).toString('base64url');

  const jar = await cookies();
  jar.set('um_state', state, { httpOnly: true, maxAge: 600, path: '/' });
  jar.set('um_verifier', verifier, { httpOnly: true, maxAge: 600, path: '/' });

  const url = new URL(`${UM}/oauth/authorize`);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', process.env.UM_CLIENT_ID!);
  url.searchParams.set('redirect_uri', REDIRECT_URI);
  url.searchParams.set('state', state);
  url.searchParams.set('code_challenge', challenge);
  url.searchParams.set('code_challenge_method', 'S256');
  return NextResponse.redirect(url);
}
// app/api/auth/um/callback/route.ts —— 回调:校验 state → 换 token → 拉 userinfo → 写 session
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const UM = 'https://um.yunjii.cn';
const REDIRECT_URI = 'https://yourapp.com/api/auth/um/callback';

export async function GET(req: NextRequest) {
  const jar = await cookies();
  const code = req.nextUrl.searchParams.get('code');
  if (!code || req.nextUrl.searchParams.get('state') !== jar.get('um_state')?.value) {
    return NextResponse.redirect('/login?error=state_mismatch');
  }

  const tokenRes = await fetch(`${UM}/oauth/token.php`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      client_id: process.env.UM_CLIENT_ID!,
      client_secret: process.env.UM_CLIENT_SECRET!,
      redirect_uri: REDIRECT_URI,
      code_verifier: jar.get('um_verifier')?.value ?? '',
    }),
  }).then((r) => r.json());

  if (tokenRes.code !== 0) {
    return NextResponse.redirect(`/login?error=${encodeURIComponent(tokenRes.msg ?? 'token_failed')}`);
  }

  const user = await fetch(`${UM}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${tokenRes.access_token}` },
  }).then((r) => r.json());

  jar.delete('um_state');
  jar.delete('um_verifier');
  jar.set('um_at', tokenRes.access_token, { httpOnly: true, path: '/', maxAge: 604800 });
  jar.set('um_rt', tokenRes.refresh_token, { httpOnly: true, path: '/', maxAge: 2592000 });
  jar.set('um_user', JSON.stringify(user), { httpOnly: true, path: '/', maxAge: 604800 });

  // TODO: 在这里用 user.sub(UMID)upsert 你的 users 表(见「UMID 落库最佳实践」)
  return NextResponse.redirect('/me');
}
// app/(user)/me/page.tsx —— 用户中心页(见上文「模式 A:服务端 Session」)

接线清单

  1. um.yunjii.cn/console/apps 创建应用,回调域名填 yourapp.com
  2. 环境变量:UM_CLIENT_ID(appid)、UM_CLIENT_SECRET(appkey)
  3. 访问 /api/auth/um/login 即可开始登录,登录后自动跳回 /me

扫码登录(二维码图片 · 兼容方式)

适合 PC 端不想跳转授权页的场景,直接 <img> 渲染二维码:

$qrUrl = $um->qrcodeUrl('wx', 200, 'state-xyz');
echo '<img src="' . htmlspecialchars($qrUrl) . '" width="200" height="200" alt="登录二维码">';

用户扫码后 UM 按 redirect_uri 回调 code,后续走上面的「5 步对接」第 3 步起。


用通用 OAuth 库接入(推荐)

UM 是标准 OAuth 2.1 + OIDC 服务,可直接用通用库接入,无需安装 UM SDK:

  • Next.js / Auth.js:配置 provider: um({ issuer: 'https://um.yunjii.cn' })
  • Python / authliboauth.register('um', server_metadata_url='https://um.yunjii.cn/.well-known/openid-configuration', ...)
  • Spring SecurityClientRegistration.withIssuer('https://um.yunjii.cn')...

通用库自动从 OIDC Discovery 读取所有端点配置。


常见问题

问题原因解决
errcode=102appid 不存在或应用未审核检查 appid,确认应用状态为"运行中"
errcode=103client_secret 不正确检查 appkey
errcode=104code 不存在/未授权/已过期code 5 分钟过期,重新发起授权
errcode=105timestamp 过期(旧版 REST)保证服务器时间正确,5 分钟内有效
errcode=107PKCE code_verifier 校验失败检查 verifier 与 challenge 是否匹配
errcode=40163code 重复消费code 只能用一次,5 分钟过期

下一步