Canvas CLI 的 SSO 接入

Portal 如何兑换用户画布凭证、传给 CLI,并完成刷新和注销

1. 整个流程是什么

用户登录 Portal → Portal 服务端用用户 SSO token 换取 EasyAI 画布 token → Portal 把画布 token 传给 CLI → CLI 操作该用户有权限的画布。

本文中的 Portal 指客户自己的门户系统。客户只需要已经部署的 EasyAI 接口地址、Portal 用户的有效 SSO token 和可安装的 Canvas CLI,不需要 EasyAI 后端源码

CLI 安装并运行在 Portal 服务端或它调用的智能体运行环境中。用户通过网页使用 Portal 时,也是由 Portal 后端启动 CLI,浏览器不直接执行命令。

Portal 用户身份认证与画布凭证兑换时序图

名称谁签发谁使用用途
Portal SSO tokenPortal 身份服务Portal 服务端提交给 EasyAI证明当前用户是谁
画布 accessTokenEasyAICLI携带在画布 API 请求中,执行授权范围内的操作
画布 refreshTokenEasyAIPortal 服务端更新画布访问凭证,不交给 CLI

兑换请求和响应中都有 accessToken 字段,但代表不同系统的 token。下文用 ssoTokencanvasSession.accessToken 区分它们。

2. 接入前需要什么

接入方准备内容
Portal提供用户身份验证接口;让服务端能获取当前用户的有效 SSO token
EasyAI 部署方配置 SSO_API_URL 指向 Portal 身份接口,并向 Portal 提供 EasyAI API 根地址
Portal 智能体运行环境安装 Canvas CLI,能够通过网络访问 EasyAI API

Canvas CLI 认证能力默认开启,不需要额外的功能开关。用户禁用、组织登录限制、设备禁用和项目权限仍然有效。

EasyAI 部署方配置:

SSO_API_URL=https://portal.example.com/auth/user

这是校验 token 并返回用户信息的后端接口地址,不是 Portal 登录页面,也不是 CLI 的 API 地址。CLI 使用的是 EasyAI API 根地址,例如 https://51easyai.com/api

EasyAI 按现有 SSO 协议请求 Portal:

GET /auth/user?access_token=用户的Portal_SSO_token

Portal 成功时返回 HTTP 200 和 JSON 对象:

{
  "id": "portal-user-001",
  "username": "alice",
  "tenantId": "tenant-a",
  "nickname": "Alice",
  "organizationIds": []
}

idusername 必须是非空字符串;id 应是稳定的用户标识。tenantId 可省略或为 null,传入时必须是字符串。organizationIds 对应已有的 EasyAI 组织 ID。用户资料、组织及角色同步规则沿用SSO 单点登录对接

Portal 应对无效、过期 token 返回 HTTP 401 或 403。EasyAI 的验证请求超时为 10 秒,不自动重试、不跟随重定向。

3. Portal 向 EasyAI 兑换凭证

请求

POST {EASYAI_API_BASE_URL}/auth/client/sso

{
  "accessToken": "当前用户的Portal_SSO_token"
}

该接口直接校验传入的 SSO token,无需先进行 EasyAI 网页登录,也不需要额外授权码。

字段必填说明
accessTokenPortal 用户 SSO token,最多 16384 字符
deviceId调用设备标识,最多 128 字符;省略时生成 UUID 并返回
agentName智能体名称,最多 80 字符;默认 EasyAI Canvas Agent
agentInstance智能体实例标识,最多 128 字符;默认使用 deviceId
clientVersion调用方版本,最多 256 字符
os运行系统,最多 256 字符

已传字段必须是非空字符串。不要传 userIdtenantIdscopesclientIdSSO_API_URL 等额外字段;这些字段会被拒绝。用户身份和权限以可信 SSO 返回的信息及 EasyAI 授权规则为准。

响应

成功返回 HTTP 200,响应正文就是会话对象,没有 status/data 包装。以下 token 和日期仅用于说明字段:

{
  "accessToken": "easyai_canvas_access_token",
  "accessTokenExpiresAt": "2026-09-11T04:10:00.000Z",
  "refreshToken": "cli_refresh_xxx",
  "refreshTokenExpiresAt": "2026-09-18T04:00:00.000Z",
  "sessionId": "cli_sess_xxx",
  "deviceId": "generated-device-uuid",
  "clientId": "easyai:canvas-cli",
  "scopes": ["canvas:read", "canvas:write", "canvas:execute", "canvas:collaborate"],
  "actor": {
    "kind": "agent",
    "principalUserId": "easyai-user-id",
    "name": "EasyAI Canvas Agent",
    "instanceId": "generated-device-uuid"
  },
  "user": {
    "id": "easyai-user-id",
    "username": "alice",
    "name": "Alice"
  }
}

Portal 应按用户保存整个响应对象。accessToken 用来调用画布;refreshTokensessionIddeviceIdclientId 用于刷新和注销;有效期字段用于决定何时刷新。

每次兑换创建新会话。相同 deviceId 不代表幂等请求,也不自动替换旧会话。正常运行应复用、刷新已有会话,不要在每条 CLI 命令前重新兑换。

4. 认证信息具体怎么交给 CLI

Portal 在启动 CLI 子进程时,通过环境变量传入 EasyAI 返回的画布 accessToken。CLI 会读取这个值,并自动添加 HTTP 请求头。

Portal 把画布访问凭证交给 CLI 的时序图

CLI 环境变量填什么
EASYAI_CANVAS_BASE_URLEasyAI API 根地址,例如 https://51easyai.com/api
EASYAI_CANVAS_ACCESS_TOKEN兑换或刷新响应中的 accessToken,不是 Portal SSO token

对应关系就是:

canvasSession.accessToken
    → CLI 子进程的 EASYAI_CANVAS_ACCESS_TOKEN
    → CLI 请求头 Authorization: Bearer <canvasSession.accessToken>

从命令的角度看,相当于:

EASYAI_CANVAS_BASE_URL="https://51easyai.com/api" \
EASYAI_CANVAS_ACCESS_TOKEN="兑换接口返回的画布accessToken" \
easyai-canvas project list

Portal 服务端应为每个子进程单独传 env,不要修改全局环境变量供多个用户共用。完整示例见下一节,它也会隔离已有 CLI 登录配置。

Portal 只把有效的画布访问凭证交给 CLI。刷新凭证留在 Portal 服务端;用户 SSO token 用于兑换,不直接用于 CLI 画布请求。此接入方式不需要执行 easyai-canvas auth login

5. 不依赖 EasyAI 源码的完整示例

在 Portal 服务端安装公开发布的 CLI:

npm install -g @easyaigc/canvas-cli@0.5.0
easyai-canvas --version

下面是可独立保存为 portal-canvas.mjs 的 Node.js 示例,使用 Node.js 22 内置能力,不依赖任何 EasyAI 后端文件。它演示“兑换 → 传凭证给 CLI → 查询项目 → 注销”。

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const execFileAsync = promisify(execFile);
const baseUrl = process.env.EASYAI_API_BASE_URL?.replace(/\/$/, '');
// 演示从环境变量取值;实际业务从当前用户的 Portal 服务端会话取得。
const ssoToken = process.env.PORTAL_SSO_ACCESS_TOKEN;
if (!baseUrl || !ssoToken) {
  throw new Error('请提供 EASYAI_API_BASE_URL 和 PORTAL_SSO_ACCESS_TOKEN');
}

async function post(path, body) {
  const response = await fetch(`${baseUrl}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(15000),
  });
  if (!response.ok) {
    await response.body?.cancel();
    throw new Error(`EasyAI ${path} 请求失败:HTTP ${response.status}`);
  }
  return response.json();
}

let canvasSession;
let configDir;
try {
  // 1. Portal SSO token 换取这个用户的 EasyAI 画布会话。
  canvasSession = await post('/auth/client/sso', {
    accessToken: ssoToken,
    agentName: 'Portal Agent',
  });

  // 2. 隔离已有 CLI 配置,避免多用户之间混用凭证。
  configDir = await mkdtemp(join(tmpdir(), 'portal-canvas-'));
  const configFile = join(configDir, 'config.json');
  await writeFile(configFile, '{}', { mode: 0o600 });
  const cliEnv = { ...process.env };
  for (const key of Object.keys(cliEnv)) {
    if (key.startsWith('EASYAI_CANVAS_')) delete cliEnv[key];
  }
  delete cliEnv.PORTAL_SSO_ACCESS_TOKEN;

  // 3. 关键步骤:把响应里的 accessToken 交给 CLI 子进程。
  const { stdout } = await execFileAsync('easyai-canvas', ['project', 'list'], {
    env: {
      ...cliEnv,
      EASYAI_CANVAS_BASE_URL: baseUrl,
      EASYAI_CANVAS_ACCESS_TOKEN: canvasSession.accessToken,
      EASYAI_CANVAS_CONFIG: configFile,
    },
    timeout: 60000,
    maxBuffer: 10 * 1024 * 1024,
  });
  // 4. CLI 已经用 Bearer token 调用了 EasyAI,stdout 是项目列表。
  console.log(stdout);
} catch {
  // 不把包含凭证或子进程上下文的原始错误输出到业务日志。
  console.error('画布操作失败,请检查接口状态、CLI 安装和用户权限。');
  process.exitCode = 1;
} finally {
  try {
    // 5. 本示例是一次性任务,执行完主动注销会话。
    if (canvasSession) {
      await post('/auth/client/logout', {
        refreshToken: canvasSession.refreshToken,
        sessionId: canvasSession.sessionId,
        deviceId: canvasSession.deviceId,
        clientId: canvasSession.clientId,
      });
    }
  } catch {
    console.error('画布会话注销失败,Portal 需补偿处理。');
    process.exitCode = 1;
  } finally {
    if (configDir) await rm(configDir, { recursive: true, force: true });
  }
}

提供实际部署地址和当前用户的有效 Portal SSO token,然后运行:

EASYAI_API_BASE_URL="https://你的EasyAI地址/api" \
PORTAL_SSO_ACCESS_TOKEN="当前用户的有效SSO_token" \
node portal-canvas.mjs

成功时输出该用户的项目列表;新用户没有项目时,列表为空也属于成功。真实服务应从当前用户的服务端会话读取 SSO token,而不是把真实 token 固定写入代码、命令历史或智能体提示词。

多次操作时,在 Portal 的受保护会话存储中保存 canvasSession,每次运行 CLI 都传入该用户当前有效的 accessToken。任务结束或用户退出时,再根据业务需要注销,不必像一次性示例那样每执行一条命令就注销。

6. 凭证过期后怎么继续使用

画布访问凭证有效期为 10 分钟;刷新凭证有效期为 7 天,每次成功刷新都会轮换,并按现有规则重新计算有效期。

Portal 应在运行 CLI 前检查 accessTokenExpiresAt,例如剩余不足 60 秒时先刷新,再启动 CLI。

Portal 刷新画布会话并在任务结束后注销的时序图

刷新调用 POST {EASYAI_API_BASE_URL}/auth/client/refresh

{
  "refreshToken": "当前refreshToken",
  "sessionId": "兑换返回的sessionId",
  "deviceId": "兑换返回的deviceId",
  "clientId": "easyai:canvas-cli"
}

保存刷新响应中的新 accessTokenrefreshToken 和有效期,并保留原 deviceId。现有刷新响应不额外返回 deviceId

同一会话的刷新必须串行,包括多个 Portal worker 之间的调用。旧刷新凭证被重复使用会触发防重放逻辑并撤销会话。不要把同一个刷新凭证复制给多个并发任务自行刷新。

已有 CLI 进程的环境变量不会随 Portal 存储更新而自动变化。因此此方案适合“Portal 按命令启动 CLI”:刷新后启动的下一个进程使用新 token;长期驻留的 CLI 进程需要另行管理续期或重启。

7. 任务结束或用户退出时怎么处理

Portal 调用 POST {EASYAI_API_BASE_URL}/auth/client/logout,请求体与刷新时使用的四个字段相同。成功后清除 Portal 保存的该会话;旧 token 将无法继续操作画布,刷新也会被拒绝。

刷新和注销接口均按 HTTP 2xx 判断成功,沿用现有接口的状态码和响应格式。

Portal 退出登录不会自动立即撤销 EasyAI CLI 会话。 若需要同步退出,由 Portal 主动调用 logout。刷新时不会再次调用 Portal SSO,因此 SSO token 过期也不等于已有画布会话立即失效;重新兑换则必须提供有效 SSO token。

8. 接口错误与接入边界

兑换接口 HTTP 状态含义
400缺少 token、字段类型错误或存在未知字段
401Portal SSO token 无效或过期
403用户禁用、组织禁止登录或设备禁用
502Portal 身份服务返回非法 JSON 或不符合约定的用户信息
503SSO 地址未配置、连接失败、超时或上游服务故障

错误体沿用 EasyAI 现有格式,以 HTTP 状态为主要判断依据,不透传 Portal 身份服务的原始错误内容。

  • 有 token 不代表有所有画布权限。 CLI 仍只能操作该用户有权限的项目。
  • CLI token 不是普通网页登录凭证。 project open 可以打开页面,但不会自动登录浏览器。
  • 写操作失败不要直接重放。 网络中断时先确认画布当前状态,避免重复创建内容。
  • 每个用户独立保存和传递凭证。 不用共享管理员 token 代替所有 Portal 用户。