接入文档
灵策中台接入指南:灵策账号登录(OIDC)与模型调用
本页目录
面向要接入灵策中台的新产品(自家子站、内部工具、合作方 Web 应用)。读完能做到两件事:
- 让用户用灵策账号登录你的产品,拿到稳定的用户 id、昵称、头像、会员状态和(可选的)组织角色;
- 用 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 的请求同样会把这枚码作废):
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{
"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 立即作废):
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。
取用户资料:
curl -s https://center.lynxce.cn/api/oauth/site/userinfo \
-H "Authorization: Bearer ssot.xxx"{ "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:
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无效、回跳地址不在应用域名内、应用不存在)回 400invalid_request或invalid_client,不跳转、不吊销。吊销写失败回 503temporarily_unavailable,可以重试。 - OIDC 未开通时回 404,与 discovery 同口径。
注意三件事登出做不到:
- 已签发的 access_token 还能调 userinfo,最长 1 小时,所以别把 access_token 留在退出后的页面里;
- 已签发的 id_token 无法撤回,它本来就只在登录那一刻用;
- 用户在灵策网页版的登录态不受影响:网页版的令牌不在中台域名下。用户再点「用灵策账号登录」时只要点一下确认就能回来,换账号要先在网页版退出。
老的不带 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 全部做了):
- header
alg必须是RS256,拒绝none、HS256等; - 按
kid从 JWKS 取公钥验签。遇到没见过的kid先重新拉一次 JWKS(服务端换钥后 kid 会变),仍没有就拒绝;JWKS 可缓存(服务端给max-age=300); iss等于 issuer;aud含你的client_id(多值时azp也必须是你);exp未过期、iat不在未来(允许约 60 秒时钟偏差);- 授权时带了
nonce就必须一致。
#6. 用 SDK 接入
SDK 还没有发布到 npm 源。接入方先用打包文件安装,打包文件找灵策运营要:
npm install /path/to/ai-employee-center-sdk-0.1.0.tgz完整的接入样例见 examples/center-demo(登录 + 收款 + Webhook + 通知 + 文件)。
零运行时依赖,只用 fetch 与 WebCrypto,Node 20+ 和浏览器都能用。fetch 可注入(代理、超时、测试桩)。
#Node 后端(推荐)
令牌只留在你的后端,浏览器只拿你自己的会话 cookie。以 Express 风格为例:
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
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。
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: "你好" }],
});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 调模型(含图片、视频生成)见 模型调用。