命令行工具经常遇到一个尴尬问题:它需要代表用户访问远程 API,但它没有浏览器,也不应该让用户在终端里输入网站密码。
qianwen-cli 的解决方案是:让浏览器负责完成用户登录,让 CLI 负责发起和领取这次授权,再用 PKCE 把两端绑定起来。登录成功后,CLI 保存服务端返回的 access_token,后续请求使用标准的 Bearer Token 鉴权。
这套流程容易被几个缩写绕晕。本文先从 OAuth 术语讲起,再回到 qianwen-cli 的实现,最后讨论它能防住什么、不能防住什么。
先说结论:它不是 AK/SK 登录
这里的账号登录不是传统云 API 常见的:
AccessKey ID + AccessKey Secret
它更接近下面这条链路:
浏览器确认用户账号
+
PKCE 绑定这次授权流程和发起请求的 CLI
↓
服务端返回 access_token
↓
CLI 用 Bearer Token 访问账号 API
模型推理命令还有一套独立的 API Key,例如 --api-key、QIANWEN_API_KEY 或 DASHSCOPE_API_KEY。那是模型调用凭证,不是 auth login 产生的用户会话。
先把几个术语讲成人话
OAuth 2.0:把“证明你是谁”和“允许应用访问”拆开
OAuth 2.0 不是一个具体的登录页面,而是一套授权协议。它把参与者分成几个角色:
| 角色 | 在 qianwen-cli 场景中的含义 |
|---|---|
| Resource Owner | 用户,也就是账号真正的拥有者 |
| Authorization Server | 负责浏览器登录和授权确认的认证服务 |
| Client | qianwen-cli |
| Resource Server | 接收 API 请求、提供模型目录、用量、账单等数据的服务 |
| Access Token | 服务端签发给 CLI 的访问凭证 |
最重要的思想是:CLI 不需要拿到用户密码。用户在浏览器里完成登录,CLI 只领取授权结果。
Device Authorization Grant:没有浏览器的设备如何登录
OAuth 的 Device Authorization Grant,也叫 Device Flow,专门解决电视、终端、打印机、命令行工具这类“不方便直接做网页跳转”的客户端。
它的典型步骤是:
- CLI 向服务端申请一笔待确认的登录会话。
- 服务端返回一个验证 URL,以及一个短期的设备登录编号。
- 用户在另一处设备的浏览器中打开 URL 并完成登录。
- CLI 定时轮询服务端,询问这笔登录是否已经完成。
- 用户确认后,服务端把访问凭证返回给 CLI。
因此,CLI 不需要嵌入浏览器。它只需要“发起登录、展示 URL、等待结果”。
PKCE:用一次性暗号绑定客户端
PKCE 的全称是 Proof Key for Code Exchange。名字很长,但机制很简单:
CLI 本地生成一个随机字符串 code_verifier
↓
计算 code_challenge = Base64URL(SHA256(code_verifier))
↓
第一次请求只提交 code_challenge
↓
第二次兑换凭证时提交原始 code_verifier
服务端在第一次请求时保存 code_challenge。第二次请求到达后,服务端重新计算:
Base64URL(SHA256(收到的 code_verifier))
如果计算结果和之前保存的 code_challenge 一致,才允许继续发放访问令牌。
这样做的目的不是确认用户是谁。用户是谁由浏览器登录决定;PKCE 负责确认:
最后领取授权结果的 CLI,确实持有最初发起这次授权时生成的那一次性暗号。
所以,PKCE 防的是“临时授权编号被截获后被别人单独兑换”,而不是“最终 access token 被盗后的重放”。
code_verifier 和 code_challenge 的关系
可以把它们看成一把钥匙的两个形态:
| 值 | 谁持有 | 什么时候出现 | 能否反推出另一方 |
|---|---|---|---|
code_verifier | CLI 本地 | 整个登录流程期间 | 不应该从 challenge 反推 |
code_challenge | 服务端 | 第一次请求后保存 | 由 verifier 单向计算 |
code_verifier 不应该被当成长期账号密码。登录成功后,它的任务就完成了,后续 API 请求不再使用它。
Access Token:登录成功后的通行证
服务端最终返回 access_token。CLI 后续请求会带上:
Authorization: Bearer <access_token>
Bearer 的含义是“持有者即可使用”。服务器通常不需要再知道这是哪个进程拿到的,只要 token 有效,就允许访问它授权的资源。
这也意味着:PKCE 可以保护兑换过程,但不能让已经泄露的 access token 失效。攻击者如果拿到了有效 token,仍然可能在它过期或被撤销前重放请求。
JWT:一种令牌格式,不是这套流程的名字
JWT 是 JSON Web Token,描述的是 token 的编码和签名格式。OAuth/PKCE 描述的是“如何授权和领取 token”的流程,两者不是一回事。
一个 OAuth 系统可以返回 JWT,也可以返回无法直接解码的随机字符串。qianwen-cli 把 access_token 当作普通字符串使用;如果 token 恰好是 JWT,CLI 只会在离线显示用户信息时读取 payload,不能把本地解码当作服务端验签。
也不要把 PKCE 误认为“JWT 双 token”:PKCE 是登录过程中的一次性校验值,常见的 access token + refresh token 则是登录成功后用于访问和续期的两种长期凭证。
qianwen-cli 的真实登录链路
现在回到源码。相关实现主要分布在:
src/api/auth-client.ts 认证协议和 HTTP 请求
src/services/auth-service.ts 登录模式编排、状态查询和降级
src/auth/login-flow.ts 轮询、两阶段登录、凭证落盘
src/auth/credentials.ts 凭证解析、过期判断和本地清理
src/auth/keychain.ts 操作系统钥匙串
src/auth/crypto-store.ts 加密文件存储
src/api/base-client.ts 后续请求的 Authorization 注入
第一步:生成持久的 client_id
CLI 会为这台机器生成一个 UUID,保存在:
~/.qianwen/device
它不是用户密码,也不是 access token,更像是认证服务用来识别这台 CLI 客户端的稳定标识。实现位于 src/auth/client-id.ts,Unix 系统下会尝试设置为仅所有者可读写的 0600。
第二步:选择登录模式
AuthService.loginInit() 根据当前是否是交互式 TTY 选择模式:
交互式终端 → PKCE 模式
非交互式环境 → Device Flow fallback
当前实现的两个路径最终都调用同一套设备授权接口,并携带 PKCE challenge。也就是说,所谓“Device Flow fallback”主要是交互方式的降级,不是完全放弃 challenge 绑定。
第三步:请求待确认的登录会话
src/api/auth-client.ts 中的 postDeviceCode() 会向认证端点发送:
POST https://t.qianwenai.com/cli/device/code
?client_id=<client_id>
&code_challenge=<challenge>
&code_challenge_method=S256
CLI 同时在内存中保留原始 code_verifier。服务端返回:
token 临时登录会话编号
verification_url 用户需要打开的 URL
expires_in 本次会话有效期
interval CLI 的轮询建议间隔
这里的 token 不是最终 access token,它只用于标识“这一次待确认登录”。
第四步:用户在浏览器中确认
交互式登录会打印 URL 并尝试打开默认浏览器:
Opening browser to authorize...
If the browser does not open, visit this URL manually: ...
浏览器中登录的是哪个账号,最终就决定 CLI 会拿到哪个账号的 access token。PKCE 不判断操作 CLI 的人和操作浏览器的人是不是同一个自然人,它只绑定这次授权流程。
第五步:CLI 轮询兑换凭证
CLI 会请求:
POST https://t.qianwenai.com/cli/device/token
?client_id=<client_id>
&token=<pending-token>
&code_verifier=<原始 verifier>
服务端应该按照下面的关系进行校验:
收到的 verifier
└─ SHA-256 + Base64URL
└─ 必须等于第一次请求保存的 code_challenge
同时还要检查临时会话是否存在、是否过期、用户是否已确认、是否已经被使用。服务端返回 complete 后,客户端将返回值规范化为:
{
access_token,
expires_at,
user: {
id,
email,
aliyunId,
}
}
登录轮询有两个实现细节值得注意:
authorization_pending会继续等待;slow_down会按 RFC 8628 的约定增加轮询间隔;网络错误会使用带抖动的退避,避免多个 CLI 同时打爆认证端点。
src/auth/login-flow.ts 负责这部分流程,并在 complete 且拿到凭证后调用 writeCredentials()。
非交互式 Agent 的两阶段登录
Agent 或 CI 不一定能打开浏览器,因此 CLI 提供:
qianwen auth login --init-only --format json
qianwen auth login --complete --format json
--init-only 只申请登录会话,把待完成状态写入:
~/.qianwen/.device-flow-pending
用户打开返回的 URL 完成确认后,再运行 --complete。后者读取待完成状态,用保存的 token、interval 和 code_verifier 执行兑换。
这个文件不是普通配置,而是短期敏感状态。当前 writePendingState() 没有像凭证文件那样显式调用 chmod(0600),因此实际权限会受目录权限和系统 umask 影响。多用户机器或共享工作区上,需要把它当作敏感文件保护。
凭证保存:钥匙串优先,加密文件兜底
登录成功后,src/auth/credentials.ts 的 writeCredentials() 选择存储层:
系统钥匙串可用且写入回读校验成功
↓
写入系统钥匙串
否则
↓
写入 AES-256-GCM 加密文件
支持的系统凭据存储包括 macOS Keychain、Linux Secret Service 和 Windows Credential Manager。
加密文件是怎么加密的
src/auth/crypto-store.ts 使用:
AES-256-GCM 加密并提供完整性校验
PBKDF2-HMAC-SHA256 从机器指纹派生加密密钥
260,000 次迭代 增加离线猜测成本
文件本身保存的是加密 envelope,包含版本号、随机 salt、随机 nonce 和密文。Unix 系统下凭证文件会尝试设置为 0600,并通过临时文件加 rename 的方式原子写入。
机器指纹优先来自硬件和系统信息;如果收集失败,代码会生成一个 HostID 作为 fallback。这个设计主要防止“把加密文件复制到另一台普通机器上直接打开”,它不是硬件安全模块:
只拿到加密文件,没有原机器环境 → 有一定保护
已经控制原机器或当前用户账号 → 仍可能运行 CLI、读取钥匙串或取出 token
QIANWEN_KEYRING=plaintext 可以强制写入明文文件,只适合调试,不适合生产环境。
凭证解析和过期判断
resolveCredentials() 按钥匙串、加密文件、旧明文文件迁移的顺序查找,并在进程内缓存一段时间。ensureAuthenticated() 会检查:
- 是否找到了凭证;
- 是否存在 access token;
expires_at是否已经过去。
当前实现没有客户端 refresh token 流程。token 过期后,CLI 会提示重新执行 auth login,而不是静默换取新 token。
access_token 是在哪里真正生效的
认证流程拿到 token 后,普通 API 请求最终都会经过 BaseClient。它根据 authMode 决定是否注入请求头:
if (authMode === 'required') {
const creds = resolveCredentials();
if (!creds) {
throw new Error('Not authenticated. Please login first.');
}
headers.Authorization = `Bearer ${creds.access_token}`;
}
请求模式有三种:
| 模式 | 行为 |
|---|---|
required | 没有登录凭证就拒绝发送 |
optional | 有凭证就带上,没有也可以请求 |
none | 完全不注入账号 token |
网关请求默认是 required,搜索等公开能力可以标记为 optional。命令层还会在进入服务前调用 ensureAuthenticated(),因此用户通常会先看到清晰的“请先登录”或“token 已过期”错误,而不是把一个未认证请求发到后端。
auth status 如何确认用户信息
qianwen auth status 不是只读本地文件。只要本地有未过期凭证,它还会请求:
GET <api.endpoint>/api/account/info.json
Authorization: Bearer <access_token>
返回状态中有一个重要字段:
server_verified: true / false
true:服务器确认 token 当前有效;false:本地有凭证,但服务器不可达或验证失败。
服务器不可达时,CLI 会降级使用本地保存的用户信息。如果本地用户字段为空,还会尝试解码 JWT payload 的常见字段,用于显示用户标识。但这只是显示用途,不是本地验签,也不能替代服务器验证。
logout 做了什么
登出分成两件事:
尽力请求服务端撤销会话
+
无论网络是否成功,都删除本地钥匙串和凭证文件
服务端撤销是 best effort。如果网络断开,本地 token 会被删除,但远端 token 是否已经失效取决于服务器的撤销机制和 token 的剩余有效期。因此怀疑凭证泄露时,不应只依赖本地 logout,还要在服务端主动撤销或重新登录使旧会话失效。
它能防住什么,不能防住什么
能防住的
- 普通 Wi-Fi 监听者直接读取 HTTPS 内容;
- 只截获临时登录编号、却没有
code_verifier的攻击者; - 把加密 credentials 文件复制到另一台没有对应机器环境的普通机器上直接读取;
- 由于 token 过期导致的无限期复用。
不能自动防住的
- 最终
access_token被窃取后的 Bearer Token 重放; - 当前电脑已经被恶意软件、管理员账号或调试器控制;
- 系统钥匙串被当前用户权限直接读取;
- 用户主动开启明文凭证模式;
- 认证服务端没有真正执行
code_challenge与code_verifier的比较; - debug 日志、待完成登录文件或其他本地日志泄露敏感参数。
尤其要注意:PKCE 保护的是“兑换 access token 的过程”,不是“access token 失窃之后的使用过程”。后者仍然是标准 Bearer Token 的风险模型。
和“JWT 双 token”有什么区别
常见的 JWT 双 token 是:
access token + refresh token
前者用于访问 API,后者用于在前者过期后换新。qianwen-cli 当前流程则是:
code_verifier + code_challenge
↓
一次性绑定登录兑换过程
↓
access_token
↓
后续 API 请求
code_verifier 不是 refresh token,也不是第二个长期身份凭证。它在登录兑换完成后就没有后续 API 用途。
从源码看懂这套设计的入口
如果要继续追代码,可以按下面顺序阅读:
| 目的 | 文件 |
|---|---|
| 登录编排和状态 | src/services/auth-service.ts |
| 初始化、轮询、PKCE 参数 | src/api/auth-client.ts |
| 轮询退避和两阶段登录 | src/auth/login-flow.ts |
| 读取、写入、删除、过期检查 | src/auth/credentials.ts |
| macOS/Linux/Windows 钥匙串 | src/auth/keychain.ts |
| AES-GCM 和机器指纹 | src/auth/crypto-store.ts |
| Authorization header 注入 | src/api/base-client.ts |
| API 请求是否必需登录 | src/api/request-adapter.ts |
| 站点端点与命名 | src/site.ts |
最后用一句话总结
qianwen-cli 的账号鉴权不是 AK/SK,也不是简单把密码塞进 CLI。它让浏览器确认用户身份,让 PKCE 证明领取结果的是原来发起授权的 CLI,再把服务端返回的 access token 安全地存起来,作为后续 API 请求的 Bearer Token。
这套设计解决了“命令行没有浏览器”的登录问题,也降低了授权码被截获的风险;但它仍然遵循 Bearer Token 的基本事实:最终 access token 一旦泄露,攻击者就可能在有效期内代表用户访问服务。