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、工作空间成员关系和角色。
Web 使用 JWT Cookie
邮箱验证码或 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 来源的非安全方法执行校验,GET、HEAD 和 OPTIONS 则直接通过。
前端 API client 统一设置 credentials: 'include',并从 Cookie 读取 CSRF token。这样页面组件不需要分别处理 Cookie 和 Header,认证传输集中在请求封装中。
CLI 借用浏览器完成登录
multica login 没有在终端里重新实现邮箱验证码和 Google OAuth。命令会启动一个临时 HTTP Server,再打开 Web 登录页,让浏览器复用现有的交互认证能力。
源码中的完整流程是:
- CLI 在可用端口启动 callback listener。
- CLI 生成随机
cli_state,把 callback URL 和 state 放入/login查询参数。 - 浏览器完成登录,或复用已经存在的 Cookie 会话。
- 新登录直接使用登录响应中的 JWT;已有 Cookie 会话调用
/api/cli-token签发新的 JWT,兼容分支也可以复用 localStorage 中的 token。 - 浏览器跳回 CLI callback,把 JWT 和 state 放入查询参数。
- CLI 比较返回的 state,拒绝不属于本次登录的 callback。
- CLI 使用这份 JWT 调用
/api/tokens,创建一枚默认 90 天有效的 PAT。 - 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 提取顺序是:
- 优先读取
Authorization: Bearer <token>。 - Header 不存在时读取
multica_authCookie。
中间件根据 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 能否成为系统中长期可信的调用方。