文档导航统一登录

接入文档

灵策中台接入指南:灵策账号登录(OIDC)与模型调用

本页目录

面向要接入灵策中台的新产品(自家子站、内部工具、合作方 Web 应用)。读完能做到两件事:

  1. 让用户用灵策账号登录你的产品,拿到稳定的用户 id、昵称、头像、会员状态和(可选的)组织角色;
  2. 用 OpenAI 兼容接口、sk- 密钥调用灵策的模型。

要在产品里卖自己的商品(微信支付 / 支付宝收款 + Webhook 通知),见 收款与 Webhook。

TypeScript 接入方优先用 SDK @ai-employee/center-sdk,它把下面的每一步都封装好了。

#1. 先决条件:在后台登记应用

目前没有自助注册,应用由灵策运营在后台 灵策中台 → 应用(仅超级管理员)登记:

字段 说明
应用 ID 即 OAuth 的 client_id。2-32 位小写字母、数字、-、_,以字母或数字开头。登记后不可改,也是用户注册来源的取值
名称 授权确认页上显示「继续前往 XX」
首页 应用首页地址
回调地址 1-10 个。逐字精确匹配:协议、域名、端口、路径、末尾斜杠都要一致;不能带查询串和 #;生产只收 https
允许新用户注册 默认开。关闭后没有灵策账号的人不会在本应用的登录流程里被建号,见下文「登录策略」
谁能登录 默认「所有灵策用户」。「仅名单成员」时只有「成员」里的用户能授权

说明:

  • 登录只用公共客户端流程:换令牌不需要 client secret,安全靠 PKCE(S256)。浏览器 SPA、移动端、服务端都按同一套流程走。
  • 浏览器里直接换令牌的 SPA,站点域名要先加进中台的 CORS_EXTRA_ORIGINS(找灵策运营);服务端换令牌不受影响。
  • 应用的 client_secret(lcs_…)只用于服务端接口(收款、通知、文件,见 收款与 Webhook 和 通知与文件),和登录无关,也绝不能放进前端。
  • 改动当前实例立即生效,蓝绿另一侧最多 60 秒。停用(status=disabled)即下线,不会删除记录。

#登录策略(内部工具用)

不开放注册(allowSignup=false):短信、邮箱验证码本来会给没账号的人自动建号,建号发生在网页版登录这一步,比用户点「继续」早。所以拦在建号点:网页版在登录流程里带上应用 id,后端建号前查这个应用的策略,手机号、邮箱、微信 / 第三方登录绑手机号、WorkBuddy 都不建号,用户看到「该应用仅限已有灵策账号登录,不开放注册」,已有账号照常登录。验证码错误时提示不变,所以外人探不出号码有没有账号。

它不是访问控制:灵策账号本身可以在 web.lynxce.cn 公开注册,注册完再来就是「已有账号」,能过这一关。approve 时另有一道回库校验,账号如果是这次登录流程开始之后才建的(比如改过的客户端没带应用 id),确认页直接拒绝,但账号本身已经存在。要限制「谁能进」请用名单。

仅名单成员(accessPolicy=allowlist):只有「成员」里的用户能通过确认页和 approve,其他人看到「你的账号没有访问该应用的权限」,不会拿到授权码。名单在中台应用列表「成员」里按手机号添加,只找已有账号、不会建号;名单为空时谁都进不来。把人移出名单后:授权码换令牌和续期立即 invalid_grant,userinfo 立即 401;已经发出去的 id_token 不能撤回,要在你的应用里以 userinfo 或续期结果为准。

#2. 流程

文本
 你的产品(浏览器)            你的后端                 灵策中台 {issuer}                 灵策网页版
      │ 点「用灵策账号登录」     │                         │                                 │
      │──────────────────────▶│ 生成 state / nonce /     │                                 │
      │                        │ code_verifier,存会话     │                                 │
      │◀──── 302 授权地址 ──────│                         │                                 │
      │──── GET /api/oauth/site/authorize?... ─────────▶│ 校验 client_id + 回调白名单      │
      │◀──────────────────────── 302 ───────────────────│──────────── 带票据 ────────────▶│
      │──────────────────────────── 用户登录(微信/手机号/邮箱)并确认授权 ─────────────────▶│
      │◀──────────────── 302 {redirect_uri}?code=ssoc_...&state=... ───────────────────────│
      │──── 回调 ─────────────▶│ 核对 state               │                                 │
      │                        │── POST /token (code + code_verifier) ──▶│               │
      │                        │◀── access_token + user + id_token (+ refresh_token) ──│  │
      │                        │ 验 id_token(签名/iss/aud/exp/nonce)                     │
      │                        │ 用 sub 绑定本地账号,建立你自己的会话                     │
      │◀──── 登录完成 ─────────│                         │                                 │
      │                        │── 之后:POST /token (refresh_token) 续期 / GET /userinfo ─▶│

用户在网页版点「取消」时,回调会带 error=access_denied&state=...。

#3. 端点

{issuer} 生产为 https://center.lynxce.cn,以 discovery 文档里的 issuer 为准,不带末尾斜杠。

端点 方法 地址
Discovery GET {issuer}/.well-known/openid-configuration
授权 GET(浏览器整页跳转) {issuer}/api/oauth/site/authorize
令牌 POST {issuer}/api/oauth/site/token
用户资料 GET {issuer}/api/oauth/site/userinfo
公钥 JWKS GET {issuer}/api/oauth/site/jwks

OIDC 未启用(服务端缺签名配置)时 discovery 与 JWKS 返回 404,带 openid 的授权请求回调 error=invalid_scope。

#授权请求参数

参数 必填 说明
response_type 是 固定 code
client_id 是 应用 ID
redirect_uri 是 登记过的回调之一,逐字一致
state 是 1-512 字符随机串,回调原样带回
code_challenge 是 BASE64URL(SHA256(code_verifier)),43 字符
code_challenge_method 是 固定 S256(不支持 plain)
scope 否 空格分隔,见下表。不带 openid 走老流程(没有 id_token)
nonce 否 1-256 个可见 ASCII 字符,原样写进 id_token。用 openid 时强烈建议带

code_verifier:43-128 个 [A-Za-z0-9-._~] 字符。

#scope

scope 作用
openid 开启 OIDC:令牌响应多出 scope 与 RS256 id_token(1 小时);userinfo 多出 sub/name/picture
profile 昵称、头像(目前 openid 下总会给,建议显式申请)
org id_token 与 userinfo 附带用户当前主组织:org_id、org_kind、org_role
offline_access 额外签发 30 天的 refresh_token(需同时有 openid)
roles id_token 与 userinfo 附带 roles: string[]:用户在本应用的角色(后台「成员」里设置),不是成员时为空数组(需同时有 openid)

未知 scope 会被忽略,服务端按 openid profile org offline_access roles 的固定顺序回写实际授予的 scope。

#4. 换令牌、续期、取资料(curl)

令牌端点收 application/x-www-form-urlencoded 或 JSON;响应一律 Cache-Control: no-store。

授权码换令牌(授权码 5 分钟有效、只能用一次;带错 code_verifier 或 redirect_uri 的请求同样会把这枚码作废):

Shell
curl -s https://center.lynxce.cn/api/oauth/site/token \
  -d grant_type=authorization_code \
  -d code=ssoc_xxx \
  -d redirect_uri=https://your-app.example.com/auth/lynxce/callback \
  -d code_verifier=<发起登录时生成的 verifier> \
  -d client_id=your-app
JSON
{
  "token_type": "Bearer",
  "access_token": "ssot.…",
  "expires_in": 3600,
  "scope": "openid profile org offline_access",
  "id_token": "eyJhbGciOiJSUzI1NiIs…",
  "refresh_token": "ssorf.…",
  "user": { "id": "cm…", "displayName": "张三", "avatarUrl": "https://…", "isMember": true }
}
  • access_token 是只读资料令牌,1 小时有效,只能调 userinfo;它不是 sk- 密钥,调不了模型,也扣不了积分。
  • user 每次都在。不带 openid 的老流程只有 token_type/access_token/expires_in/user 四个字段。
  • 子站拿到的资料只有这些:没有手机号、邮箱和积分。avatarUrl 只给 https 地址,没有则为 null。

续期(每次返回一整组新令牌,旧 refresh_token 立即作废):

Shell
curl -s https://center.lynxce.cn/api/oauth/site/token \
  -d grant_type=refresh_token \
  -d refresh_token=ssorf.xxx \
  -d client_id=your-app

续期签发的 id_token 不带 nonce,验签时不要传 nonce。

取用户资料:

Shell
curl -s https://center.lynxce.cn/api/oauth/site/userinfo \
  -H "Authorization: Bearer ssot.xxx"
JSON
{ "sub": "cm…", "name": "张三", "picture": "https://…", "org_id": "…", "org_kind": "enterprise", "org_role": "admin",
  "user": { "id": "cm…", "displayName": "张三", "avatarUrl": "https://…", "isMember": true } }

userinfo 每次都回库核对:账号停用、用户「退出所有设备」或改密码后立即返回 401。

#单点登出

GET {issuer}/api/oauth/site/logout(discovery 的 end_session_endpoint,OIDC RP-Initiated Logout)。用户点「退出」时,先清你自己的会话,再把浏览器整页跳过去,不要从服务端调用。

参数 必填 说明
id_token_hint 否,但不传就不吊销 登录或续期时拿到的 id_token,过期了也能用。中台只凭它确认是哪个用户
client_id 否 没有 id_token_hint 时用来确定应用;两个都传必须是同一个应用
post_logout_redirect_uri 否 退出后跳回的地址,必须与应用已登记回调同源(协议、主机、端口一致,路径不限),不能带查询串或片段。生产只收 https
state 否 原样带回 post_logout_redirect_uri,最长 512 个字符
文本
https://center.lynxce.cn/api/oauth/site/logout?id_token_hint=eyJ...&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2F&state=abc

用 SDK:

TypeScript
await clearLocalSession();
res.redirect(center.logout({ idTokenHint: session.idToken, postLogoutRedirectUri: "https://app.example.com/", state }));

行为:

  • 带有效 id_token_hint:吊销这个用户在你这个应用此刻之前签发的全部 refresh_token,之后续期一律 invalid_grant。用户在其它应用的登录不受影响。
  • 只带 client_id:不吊销(这是公开的 GET 地址,不能只凭应用 ID 动任何人的令牌),校验回跳地址后照常跳转。
  • 没带 post_logout_redirect_uri:显示中台的「已退出」页面;请求头 Accept: application/json 时回 {"logged_out": true, "revoked": true}。
  • 参数不对(id_token_hint 无效、回跳地址不在应用域名内、应用不存在)回 400 invalid_request 或 invalid_client,不跳转、不吊销。吊销写失败回 503 temporarily_unavailable,可以重试。
  • OIDC 未开通时回 404,与 discovery 同口径。

注意三件事登出做不到:

  1. 已签发的 access_token 还能调 userinfo,最长 1 小时,所以别把 access_token 留在退出后的页面里;
  2. 已签发的 id_token 无法撤回,它本来就只在登录那一刻用;
  3. 用户在灵策网页版的登录态不受影响:网页版的令牌不在中台域名下。用户再点「用灵策账号登录」时只要点一下确认就能回来,换账号要先在网页版退出。

老的不带 openid 的流程没有 id_token,也不支持登出吊销,行为不变。

#5. 校验 id_token

id_token 是 RS256 签名的 JWT,header 带 kid。claims:

claim 说明
iss 等于 issuer,逐字比较
sub 灵策用户 id(等于 user.id),用它绑定本地账号,不要用昵称或手机号
aud / azp 你的 client_id
iat / exp 签发 / 过期时间(秒),有效期 1 小时
jti 令牌 id
nonce 授权时带了才有
name / picture 昵称 / 头像(无头像不出现)
is_member 是否灵策会员
org_id / org_kind / org_role scope 含 org 时出现
roles scope 含 roles 时出现,字符串数组,可能为空

必须做的校验(SDK 的 verifyIdToken 全部做了):

  1. header alg 必须是 RS256,拒绝 none、HS256 等;
  2. 按 kid 从 JWKS 取公钥验签。遇到没见过的 kid 先重新拉一次 JWKS(服务端换钥后 kid 会变),仍没有就拒绝;JWKS 可缓存(服务端给 max-age=300);
  3. iss 等于 issuer;aud 含你的 client_id(多值时 azp 也必须是你);
  4. exp 未过期、iat 不在未来(允许约 60 秒时钟偏差);
  5. 授权时带了 nonce 就必须一致。

#6. 用 SDK 接入

SDK 还没有发布到 npm 源。接入方先用打包文件安装,打包文件找灵策运营要:

Shell
npm install /path/to/ai-employee-center-sdk-0.1.0.tgz

完整的接入样例见 examples/center-demo(登录 + 收款 + Webhook + 通知 + 文件)。

零运行时依赖,只用 fetch 与 WebCrypto,Node 20+ 和浏览器都能用。fetch 可注入(代理、超时、测试桩)。

#Node 后端(推荐)

令牌只留在你的后端,浏览器只拿你自己的会话 cookie。以 Express 风格为例:

TypeScript
import { CenterClient, CenterOAuthError, CenterSdkError } from "@ai-employee/center-sdk";

const center = new CenterClient({
  issuer: "https://center.lynxce.cn",
  clientId: "your-app",
  redirectUri: "https://your-app.example.com/auth/lynxce/callback",
  scope: "openid profile org offline_access",
});

app.get("/auth/lynxce/login", async (req, res) => {
  const login = await center.createLoginRequest();
  req.session.lynxce = { state: login.state, nonce: login.nonce, codeVerifier: login.codeVerifier };
  res.redirect(login.url);
});

app.get("/auth/lynxce/callback", async (req, res) => {
  const pending = req.session.lynxce;
  delete req.session.lynxce; // 一次性
  if (!pending) return res.status(400).send("登录已过期,请重新登录");
  try {
    const { tokens, claims } = await center.handleCallback({
      callback: new URL(req.originalUrl, "https://your-app.example.com"),
      expectedState: pending.state,
      codeVerifier: pending.codeVerifier,
      nonce: pending.nonce,
    });
    const user = await upsertLocalUser({ lynxceId: claims!.sub, name: claims!.name, orgRole: claims!.org_role });
    await saveRefreshToken(user.id, tokens.refresh_token); // 加密存储
    req.session.userId = user.id;
    res.redirect("/");
  } catch (error) {
    if (error instanceof CenterOAuthError && error.error === "access_denied") return res.redirect("/login?cancelled=1");
    if (error instanceof CenterSdkError) return res.status(400).send(`登录失败:${error.message}`);
    throw error;
  }
});

// 续期:必须保存新的 refresh_token
async function renew(userId: string, refreshToken: string) {
  try {
    const next = await center.refresh(refreshToken);
    await saveRefreshToken(userId, next.refresh_token);
    return next;
  } catch (error) {
    if (error instanceof CenterOAuthError && error.error === "invalid_grant") {
      await clearLocalSession(userId); // 令牌已用过 / 用户退出所有设备 / 改了密码:要求重新登录
      return null;
    }
    throw error;
  }
}

不想用 CenterClient 也可以单独用函数:createPkcePair、randomState、randomNonce、buildAuthorizeUrl、buildLogoutUrl、exchangeCode、refreshTokens、fetchUserInfo、verifyIdToken(多次验签请复用一个 JwksCache 实例传给 jwks)。

#浏览器 SPA

TypeScript
import { CenterClient } from "@ai-employee/center-sdk";

const center = new CenterClient({
  issuer: "https://center.lynxce.cn",
  clientId: "your-spa",
  redirectUri: `${location.origin}/auth/callback`,
  scope: "openid profile",
});

export async function login() {
  const req = await center.createLoginRequest();
  sessionStorage.setItem("lynxce_login", JSON.stringify(req)); // 只放 sessionStorage,不放 localStorage
  location.assign(req.url);
}

export async function handleCallback() {
  const pending = JSON.parse(sessionStorage.getItem("lynxce_login") ?? "null");
  sessionStorage.removeItem("lynxce_login");
  if (!pending) throw new Error("登录已过期");
  const { tokens, claims } = await center.handleCallback({
    callback: location.href,
    expectedState: pending.state,
    codeVerifier: pending.codeVerifier,
    nonce: pending.nonce,
  });
  history.replaceState(null, "", "/"); // 把 code 从地址栏清掉
  return { user: tokens.user, claims };
}

注意:浏览器直接调 /token、/userinfo 受 API 的 CORS 白名单限制(生产需要把你的域名加进 CORS_EXTRA_ORIGINS)。更推荐的做法是 SPA 只负责跳转和拿 code,把 code + code_verifier 交给你自己的后端去换令牌(BFF),这样 refresh_token 也不会落在浏览器里。纯 SPA 不建议申请 offline_access。

#7. 错误码

令牌与 userinfo 端点返回 OAuth 风格 { "error": "...", "error_description": "中文说明" };授权端点出错时把 error、error_description、state 拼在回调地址上(未知 client_id 或回调不在白名单时不会跳转,直接 400 JSON)。

error HTTP 场景 处理
invalid_client 400 client_id 未登记或已停用 检查后台登记
invalid_request 400 缺参数、回调不在白名单、state 缺失或超长、非 S256 PKCE、nonce 不合法、grant_type 不支持 修请求
unsupported_response_type 回调 response_type 不是 code 修请求
invalid_scope 回调 申请了 openid 但服务端 OIDC 未开放 联系灵策,或去掉 openid
access_denied 回调 用户点了取消 回到登录页
invalid_grant 400 授权码无效、已用、过期或不属于本应用;redirect_uri 与授权时不一致;PKCE 不匹配;账号不可用;续期令牌已用过、过期、被「退出所有设备」或单点登出吊销;用户已被移出应用名单 重新发起登录
invalid_token 401 userinfo 的访问令牌无效、过期或账号已退出;用户已被移出应用名单 续期或重新登录
slow_down 429 触发限流 退避重试
temporarily_unavailable 503 授权码存储(Redis)或签名配置暂不可用 稍后重试
server_error 500 服务端异常 稍后重试

全部错误码和处理办法见 错误码。

SDK 的错误类型:CenterOAuthError(上表,error / description / status,来自回调时 status 为 0)、IdTokenError(code:malformed、unsupported_alg、unknown_kid、jwks_unavailable、invalid_signature、invalid_issuer、invalid_audience、expired、issued_in_future、nonce_mismatch、missing_claim),其余为 CenterSdkError(state_mismatch、missing_code、subject_mismatch、network_error、invalid_config、invalid_response)。三者都继承 CenterSdkError。

#8. 安全要求

  • state:每次登录新生成,存在发起方(服务端会话或 sessionStorage),回调逐字比对,用完即删。出错回调同样要核 state。
  • nonce:用 openid 时每次新生成,验 id_token 时比对,防重放。
  • PKCE:code_verifier 只存在发起方,绝不放进 URL 或日志。
  • 回调精确匹配:后台登记什么就传什么,不做前缀或通配匹配;回调页不要再根据参数做开放跳转。
  • 续期令牌轮换:每次续期返回新的 refresh_token,旧的立即作废;同一枚用第二次一律 invalid_grant(视为可能泄露)。多实例或多标签页要串行续期,并且只保存最新那枚(SDK 的 CenterClient.refresh 会合并同一进程内的并发续期)。
  • 退出所有设备:用户在灵策「退出所有设备」或改密码后,该用户所有 access_token、refresh_token 立即失效(userinfo 401、续期 invalid_grant)。你的产品应把这两种响应当作「已退出」处理。要只退出你自己这个应用,用下面的单点登出。
  • 单点登出:见 § 单点登出。回跳地址只能是你已登记回调的同源地址,登出页面不要再根据参数跳转。
  • 令牌存储:refresh_token 存后端并加密,access_token 不要长期保存;日志里不要打印任何令牌和授权码。
  • 账号绑定用 sub:不要用昵称、头像去识别用户。
  • 资料令牌没有其它权限:它只能调 userinfo。要调模型,请用下一节的 sk- 密钥。

#9. 调用模型(OpenAI 兼容,sk- 密钥)

登录令牌不能调模型。模型调用走开放网关:

  • Base URL:https://api.lynxce.cn/api/v1
  • 鉴权:Authorization: Bearer sk-…(或 X-Lynxce-API-Key: sk-…)
  • 密钥:企业集成密钥,由企业 owner / 管理员在用户门户「个人中心 → API 密钥」(/profile/api-keys)创建,可限定权限(model:chat、media:image、media:video)和可用模型。扣费走企业钱包、按企业积分倍率结算,余额不足返回 402。
  • 密钥只放你的后端,不要下发到浏览器、小程序或 App。
TypeScript
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.LYNXCE_API_KEY, // sk-...
  baseURL: "https://api.lynxce.cn/api/v1",
});

const models = await client.models.list(); // 该密钥可用的模型
const completion = await client.chat.completions.create({
  model: models.data[0]!.id,
  messages: [{ role: "user", content: "你好" }],
});
Shell
curl -s https://api.lynxce.cn/api/v1/chat/completions \
  -H "Authorization: Bearer $LYNXCE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"<模型 id>","messages":[{"role":"user","content":"你好"}]}'

中台应用用自己的 Key 调模型(含图片、视频生成)见 模型调用。