VINO/WANG返回博客 ←

BLOG / POST

从 Multica 源码看 Web 与 CLI 的双凭证设计

沿着 Multica 的登录、Cookie、CLI 浏览器回调、PAT 与鉴权中间件源码,拆解 Web 和 CLI 如何共享身份并使用不同凭证。

  • auth
  • cli
  • security
  • go

同一个产品同时提供 Web 和 CLI 时,两端可以共享用户身份,却不适合长期携带同一种凭证。浏览器天然支持 Cookie 和交互式登录,CLI 更适合显式发送可以保存、检查和撤销的 token。

最近阅读了 Multica 在 revision 1869b1b5e 的登录与鉴权源码。这套实现让 Web 使用 JWT Cookie,让 CLI 使用 Personal Access Token(PAT),并在服务端中间件中把两条认证路径收口为同一个用户身份。

本文只记录这次源码阅读得到的事实和设计理解,没有进行运行时验证或独立安全审计。Multica 还存在 daemon token、Agent task token 等机器凭证;这里仅讨论普通用户直接接触的 Web JWT 与 CLI PAT。

源码阅读范围

这条调用链横跨 Web、CLI 和 Go 服务端,核心文件如下:

文件 作用
server/internal/handler/auth.go 邮箱验证码、Google OAuth、JWT 签发和 CLI 临时 JWT 接口
server/internal/auth/cookie.go Web Cookie 与 CSRF token 的生成和校验
server/cmd/multica/cmd_auth.go CLI 浏览器登录、本地 callback、PAT 保存与退出
server/internal/handler/personal_access_token.go PAT 创建、查询、续期与撤销
server/internal/middleware/auth.go JWT、PAT 和其他 token 的统一认证入口
server/internal/middleware/workspace.go 认证完成后的工作空间成员与角色检查

从职责划分可以先得到一个边界:身份签发、凭证校验和工作空间授权都在 Go 服务端完成。Web 页面负责发起登录、携带 Cookie 和完成跳转;CLI 负责接收凭证、保存配置并调用 API。

一套身份,两种凭证

Multica 的 Web 和 CLI 最终都代表 users 表中的同一个用户。两端的区别在于后续请求如何证明这个身份。

使用环境 长期使用的凭证 携带方式
Web JWT multica_auth HttpOnly Cookie
CLI mul_ 前缀的 PAT Authorization: Bearer <PAT>

整体流程可以压缩成下面这条链路:

邮箱验证码 / Google OAuth


        服务端签发 JWT
          │       │
          │       └─ 浏览器 Cookie ───────────┐
          │                                    │
          └─ 浏览器回调交给 CLI                │
                       │                       │
                       ▼                       ▼
                  CLI 创建 PAT          Auth Middleware
                       │                       │
                       └─ Bearer PAT ─────────┘


                                            User ID


                                  Workspace Member / Role

这里的两种凭证承担不同的客户端会话职责。服务端完成认证后,后续业务只需要继续处理用户 ID、工作空间成员关系和角色。

邮箱验证码或 Google OAuth 登录成功后,auth.go 中的 issueJWT 使用 HS256 签发 JWT。claims 包含用户 ID、邮箱、名称、签发时间和过期时间,默认有效期为 30 天。

SetAuthCookies 随后写入两个 Cookie:

Cookie 属性 用途
multica_auth HttpOnly、SameSite=Strict 保存 JWT,浏览器请求时自动携带
multica_csrf JavaScript 可读、SameSite=Strict 写请求时复制到 X-CSRF-Token Header

CSRF token 由随机 nonce 和 HMAC-SHA256 签名组成,签名密钥是当前 JWT。服务端对 Cookie 来源的非安全方法执行校验,GETHEADOPTIONS 则直接通过。

前端 API client 统一设置 credentials: 'include',并从 Cookie 读取 CSRF token。这样页面组件不需要分别处理 Cookie 和 Header,认证传输集中在请求封装中。

CLI 借用浏览器完成登录

multica login 没有在终端里重新实现邮箱验证码和 Google OAuth。命令会启动一个临时 HTTP Server,再打开 Web 登录页,让浏览器复用现有的交互认证能力。

源码中的完整流程是:

  1. CLI 在可用端口启动 callback listener。
  2. CLI 生成随机 cli_state,把 callback URL 和 state 放入 /login 查询参数。
  3. 浏览器完成登录,或复用已经存在的 Cookie 会话。
  4. 新登录直接使用登录响应中的 JWT;已有 Cookie 会话调用 /api/cli-token 签发新的 JWT,兼容分支也可以复用 localStorage 中的 token。
  5. 浏览器跳回 CLI callback,把 JWT 和 state 放入查询参数。
  6. CLI 比较返回的 state,拒绝不属于本次登录的 callback。
  7. CLI 使用这份 JWT 调用 /api/tokens,创建一枚默认 90 天有效的 PAT。
  8. CLI 用新 PAT 请求 /api/me 做一次确认,再保存到本地配置。
multica login

  ├─ 启动 callback listener
  ├─ 生成 cli_state
  └─ 打开 /login?cli_callback=...&cli_state=...


           浏览器完成或确认 Web 登录


             登录响应 / /api/cli-token


             callback?token=<JWT>&state=...


                CLI 校验 state 并创建 PAT

Web 侧会先用 validateCliCallback 校验 callback URL,只接受 HTTP loopback 或受支持的私有网络地址。cli_state 负责把返回结果绑定到发起登录的 CLI 进程。

PAT 只在创建时返回明文

服务端生成的 PAT 以 mul_ 开头,后面是 20 字节随机数的十六进制表示。创建接口把完整 token 返回给客户端,同时只把以下信息写入数据库:

字段 用途
token_hash SHA-256 哈希,用于认证查询
token_prefix 短前缀,用于列表展示和人工识别
expires_at 可选过期时间
last_used_at 最近使用时间
revoked 主动撤销状态

收到 PAT 后,CLI 把它保存到 ~/.multica/config.json。配置写入使用临时文件和原子重命名,最终文件权限设置为 0600。之后的 CLI 请求都显式发送 Bearer PAT。

认证查询只接受未撤销且尚未过期的 PAT。服务端根据传入 token 的 SHA-256 哈希查找记录,成功后把对应用户 ID 注入请求,并更新最近使用时间。

multica auth logout 只清除本地配置中的 PAT,不会撤销服务端记录。怀疑 token 泄漏时,需要在 Personal Access Tokens 页面执行 revoke;单纯退出 CLI 无法让另一份 token 副本失效。

两条认证路径在中间件汇合

middleware.Auth 的 token 提取顺序是:

  1. 优先读取 Authorization: Bearer <token>
  2. Header 不存在时读取 multica_auth Cookie。

中间件根据 token 类型进入不同分支。mul_ PAT 先计算哈希并查询数据库;普通 JWT 则验证签名、有效期和 claims。两条分支最终都写入 X-User-ID,下游 Handler 不需要知道请求来自 Web 还是 CLI。

CSRF 只作用于 Cookie 分支。浏览器会自动附带 Cookie,第三方页面可能借用这份会话发起写请求;CLI 的 Bearer Header 由客户端显式设置,不处在同一个威胁模型中。

认证完成后,工作空间中间件继续解析目标 workspace,查询当前用户的成员记录,并根据路由要求检查角色。这里把三个概念分成了连续的服务端关卡:

层次 回答的问题 Multica 中的权威位置
认证 请求代表谁 middleware.Auth
凭证 用什么证明身份 JWT Cookie 或 Bearer PAT
授权 可以访问哪个 workspace、执行什么操作 workspace middleware 与 Handler

前端隐藏按钮或 CLI 提前提示只能改善交互,最终授权仍由服务端完成。

从源码看到的几个取舍

第一,浏览器是 CLI 的交互认证代理。CLI 不需要处理验证码表单和 OAuth callback,但仍能获得适合长期 API 调用的 PAT。

第二,JWT 会经过 callback URL。state 校验可以阻止其他登录流程把结果注入当前 CLI,callback 地址校验也限制了跳转目标;JWT 本身仍然出现在浏览器跳转 URL 中。从暴露面看,使用短时、一次性 authorization code,再由 CLI 后端交换长期凭证,会比直接传递 JWT 更容易控制泄漏后的有效窗口。这是基于源码的数据流观察,不代表已经验证出可利用漏洞。

第三,PAT 代表完整用户身份。Multica 的文档明确区分 PAT 与范围更窄的 daemon token;常驻 daemon 应使用绑定工作空间的机器凭证,避免把用户级 PAT 扩散到长期运行的进程。

第四,退出与撤销是两个动作。CLI logout 只删除本地 token,revoke 才会让服务端拒绝已经泄漏的副本。命令行提示和文档需要把这一区别说清楚。

第五,JWT 校验路径是无状态的。从所读版本的中间件看,服务端验证签名和 claims 时没有查询 Session 表,因此清除浏览器 Cookie 只会结束当前浏览器持有的会话副本。若产品需要立即踢下线、账号禁用或逐设备撤销,还需要额外的服务端会话状态。这一条属于源码推断,没有经过运行时验证。

总结

Multica 的 Web 与 CLI 共享用户和工作空间权限模型,同时选择适合各自环境的凭证。Web 使用 HttpOnly JWT Cookie,并用 CSRF token 保护自动携带 Cookie 的写请求;CLI 通过浏览器完成交互认证,再创建可查询、过期和撤销的 PAT。

服务端中间件把 Cookie JWT 与 Bearer PAT 还原为同一个用户 ID,工作空间中间件继续完成租户和角色授权。这种结构把客户端体验分开,把身份与权限规则集中在服务端。

为什么 SaaS 还要提供 CLI?过去大多数应用默认服务对象是人:人在浏览器里登录,通过页面、表单和按钮完成工作,Cookie 代表这段交互会话。Agent 成为新的使用者后,同样需要读取数据、执行操作和提交结果,却不适合依赖页面点击,也不应该借用某个浏览器中的 Cookie。Agent 需要独立、明确、可过期、可撤销的身份凭证,服务端也需要知道每次调用属于哪个用户和工作空间。

CLI 为这些能力提供了稳定的命令、参数、退出码和结构化输出。Agent 可以组合命令,把它们放进循环、计划或本地 Runtime,并在失败时读取明确反馈。对人来说,CLI 同时保留了脚本化、批处理和自动化入口;一套清晰、可发现、反馈稳定的调用面,往往也会同时改善人和 Agent 的使用体验。

把业务能力开放为 CLI 调用面,不会改变服务端的权威边界。CLI 负责组织输入、调用 API、访问经过授权的本地资源并呈现结果;服务端继续负责身份、数据、工作空间隔离和最终授权。Web、CLI 和 Agent 因此复用同一套业务语义,也接受同一套安全约束。

如今的 SaaS 应用不光服务人,也要为 Agent 提供可靠的操作入口。人继续使用 Web 设定目标、处理例外和审阅结果;Agent 通过 CLI 执行可重复的业务动作。登录只是入口,凭证如何保存和撤销、调用如何归属和审计、跨租户请求在哪里被拦截,决定了 Agent 能否成为系统中长期可信的调用方。

参考资料