← blog 技术原理与实践 · 2026-09-22

从 PKCE 到 qianwen-cli:一次浏览器登录是怎样变成 CLI 凭证的

先把 OAuth、Device Flow、PKCE、Bearer Token 和 JWT 讲清楚,再沿着 qianwen-cli 的源码拆解登录、轮询、凭证存储、请求注入与安全边界。

13 min read

命令行工具经常遇到一个尴尬问题:它需要代表用户访问远程 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负责浏览器登录和授权确认的认证服务
Clientqianwen-cli
Resource Server接收 API 请求、提供模型目录、用量、账单等数据的服务
Access Token服务端签发给 CLI 的访问凭证

最重要的思想是:CLI 不需要拿到用户密码。用户在浏览器里完成登录,CLI 只领取授权结果。

Device Authorization Grant:没有浏览器的设备如何登录

OAuth 的 Device Authorization Grant,也叫 Device Flow,专门解决电视、终端、打印机、命令行工具这类“不方便直接做网页跳转”的客户端。

它的典型步骤是:

  1. CLI 向服务端申请一笔待确认的登录会话。
  2. 服务端返回一个验证 URL,以及一个短期的设备登录编号。
  3. 用户在另一处设备的浏览器中打开 URL 并完成登录。
  4. CLI 定时轮询服务端,询问这笔登录是否已经完成。
  5. 用户确认后,服务端把访问凭证返回给 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_verifierCLI 本地整个登录流程期间不应该从 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() 会检查:

  1. 是否找到了凭证;
  2. 是否存在 access token;
  3. 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 一旦泄露,攻击者就可能在有效期内代表用户访问服务。

Sources

No external sources for this entry.

Related