跳转到内容

cnb-rs-auth-gh-alignment

6,826 字 24 分钟

cnb-rs Auth 对齐 gh 改造设计说明 ​

1. 文档定位 ​

本文档不是简单的变更摘要,而是一份偏设计说明性质的记录,目标是把这次 cnb-rs auth 改造讲清楚,方便您后续从下面几个角度审视:

  • 设计目标是否合理
  • 模型拆分是否稳定
  • 与 gh auth 的对齐程度是否足够
  • 命令行为是否自洽
  • 兼容旧配置的策略是否稳妥
  • 后续还有哪些扩展点和技术债

本文既适合代码审查,也适合作为后续继续演进认证体系时的上下文文档。

2. 背景 ​

在改造之前,cnb-rs 的认证体系本质上是一个“单域名单账号 + 配置文件明文 token”的模型:

  1. auth login 验证 token 后,直接写入 ~/.cnb/config.toml
  2. auth status 仅识别“环境变量”或“配置文件”两类来源
  3. auth logout 只会删除配置文件中的该域名 token
  4. 同一域名无法保存多个账号
  5. 没有系统凭证存储,也没有 keyring 不可用时的 fallback 分支

这个模型的问题很明显:

  • 默认明文落盘,不符合 gh 的默认安全策略
  • 结构上无法自然扩展到多账号
  • 没有“当前激活账号”的显式概念
  • status 和 logout 的语义与未来 keyring 模型不兼容
  • 如果以后再补 keyring,旧结构改造成本会越来越高

而 gh auth login 的用户心智已经比较成熟:

  • 默认安全存储
  • 存储失败自动回退
  • 环境变量优先
  • 本地多账号
  • 激活账号可切换

因此,这次改造的目标不是“给 auth login 加个 keyring”,而是把整个认证模型升级到接近 gh 的层级。

3. 改造目标 ​

这次改造的目标可以分成功能目标、体验目标和工程目标三类。

3.1 功能目标 ​

  1. 默认将 token 存到系统凭证存储,而不是默认写明文配置
  2. 当系统凭证存储不可用时,自动回退到配置文件
  3. 支持同一域名下保存多个账号
  4. 支持切换当前激活账号
  5. status 能准确区分 token 来源
  6. logout 能按当前激活账号退出,而不是粗暴删除整个域名配置

3.2 体验目标 ​

  1. 用户默认得到更安全的存储行为
  2. 环境变量认证生效时,不让本地保存操作产生误导
  3. 多账号行为尽量接近 gh
  4. status 信息比之前更可诊断,尤其是在 keyring 异常场景下

3.3 工程目标 ​

  1. 将“配置元数据”和“secret 存储”解耦
  2. 将“命令层逻辑”和“认证业务层逻辑”解耦
  3. 对旧配置保持兼容读取
  4. 为后续浏览器登录流、更多 auth 子命令预留扩展空间

4. 非目标 ​

这次改造有意没有覆盖以下内容:

  1. 没有实现浏览器授权流或 device flow
  2. 没有做真正跨平台桌面环境的 keyring 集成测试
  3. 没有引入 auth list
  4. 没有引入 auth token 之类的敏感输出命令
  5. 没有复制 gh 的“host-level active token 空用户名槽位”模型

这些不是遗漏,而是有意控制范围,先把当前最关键的认证地基打稳。

5. 参考对象与对齐结论 ​

本次参考的是工作区里的 cli/ 仓库,也就是 GitHub CLI 的实现。

重点参考了 gh 的以下几个行为:

  1. auth login 默认走系统凭证存储
  2. keyring 不可用时回退到明文配置
  3. 认证解析顺序优先环境变量
  4. 本地保存多账号,并显式记录当前激活账号
  5. logout 时按账号删除,而不是简单删 host token

5.1 已对齐的部分 ​

  • 默认 keyring 存储
  • 自动 fallback 到配置文件
  • 环境变量优先级最高
  • 环境变量认证生效时拒绝覆盖本地凭证
  • 同域名多账号
  • 当前激活账号切换
  • logout 自动切换剩余账号

5.2 刻意保留的差异 ​

gh 在 keyring 中会保留两类记录:

  1. service = gh:<host>, account = <username>
  2. service = gh:<host>, account = ""

第二类实际上是“当前激活 token”的快捷槽位。

cnb-rs 没有照搬这一层,而是采用:

  1. keyring 中只保存 service = cnb-rs:<domain>, account = <username>
  2. 当前激活账号用配置文件中的 user 字段表示

这样做的原因:

  1. cnb-rs 自己控制认证解析,不需要兼容 go-gh 的接口
  2. 避免相同 token 存两份
  3. 行为更简单,也更容易推导

因此,这次是“行为对齐优先,内部实现不盲目复制”。

6. 总体架构 ​

改造后,认证系统被拆成三层。

6.1 配置层 ​

配置层只负责保存“账号元数据”,例如:

  • 当前域名有哪些账号
  • 当前激活账号是谁
  • 某个账号的 token 存在 keyring 还是 config

配置层不再默认承担 secret 存储职责。

6.2 凭证存储层 ​

新增了一个独立的系统凭证存储抽象,用于封装平台相关的 keyring 访问:

  • get
  • set
  • delete

这一层对外暴露统一错误语义,并负责超时保护。

6.3 认证业务层 ​

认证业务层负责业务决策:

  • 登录成功后怎么存
  • 当前 token 应该从哪里取
  • 什么情况下认为“有元数据但 token 不可用”
  • 切换账号前需要做什么校验
  • 退出当前账号后是否要切换到其他账号

6.4 命令层 ​

命令层只负责:

  • 参数解析
  • 提示信息
  • 调用认证业务层
  • 把业务层结果翻译成最终用户可读文案

7. 模块与文件职责 ​

下面按文件说明职责边界。

7.1 cnb/crates/cnb-core/src/credential_store.rs ​

职责:

  • 抽象系统凭证存储
  • 把 keyring crate 错误转换为本项目内部错误
  • 对每次 keyring 访问增加超时保护

它是平台适配层,不负责 auth 策略。

7.2 cnb/crates/cnb-core/src/config.rs ​

职责:

  • 维护新的 auth 配置 schema
  • 提供兼容旧配置结构的读取能力
  • 提供账号级别的增删改查辅助方法
  • 提供 active user 切换和删除时的配置级逻辑

它负责“数据结构与持久化”,不负责命令行为。

7.3 cnb/crates/cnb-core/src/auth.rs ​

职责:

  • 定义认证解析结果类型
  • 实现 env > keyring > config 的解析顺序
  • 实现 login 的持久化策略
  • 实现 switch 的校验与切换
  • 实现 logout 的删除与自动切换

它是整个认证模型的核心。

7.4 cnb/src/commands/auth/login.rs ​

职责:

  • 读取 token 输入
  • 调 /user 校验 token
  • 在环境变量生效时阻止覆盖本地存储
  • 调 persist_login
  • 根据 LoginResult 决定是否输出 fallback 警告

7.5 cnb/src/commands/auth/status.rs ​

职责:

  • 调 resolve_auth
  • 根据 AuthLookup 的不同分支显示状态
  • 用 API 校验 token 并补充用户名
  • 在 token 失效时回退显示本地用户名

7.6 cnb/src/commands/auth/logout.rs ​

职责:

  • 环境变量认证时提示用户手动 unset
  • 本地认证时调用 auth::logout
  • 根据是否切换到其他账号来调整提示文案

7.7 cnb/src/commands/auth/switch.rs ​

职责:

  • 列出当前域名下可切换的账号
  • 支持参数式切换或简易交互式选择
  • 在环境变量认证生效时拒绝切换
  • 调 auth::switch_user

8. 新的数据结构 ​

这次改造最关键的不是命令,而是数据结构。

8.1 Config ​

仍然是整个配置文件的顶层结构,保留原有的:

  • domain
  • git_protocol
  • auth

其中 auth 是这次改造的主要变化点。

8.2 AuthConfigToml ​

本质是:

rust
pub struct AuthConfigToml {
    pub hosts: BTreeMap<String, HostAuth>,
}

也就是以域名为 key,映射到某个域名下的认证配置。

选择 BTreeMap 而不是 HashMap 的一个好处是:

  • 配置文件写回顺序稳定
  • 对 review 和 diff 更友好

8.3 HostAuth ​

这是整个模型里最关键的结构之一。

它现在包含四部分信息:

  1. user 当前激活账号
  2. users 当前域名下的所有账号
  3. legacy_token 兼容旧版单账号明文 token
  4. legacy_username 兼容旧版单账号用户名

也就是说,它既能表达新的多账号模型,也能在读取时容忍旧格式。

8.4 UserAuth ​

每个账号一条记录,包含:

  1. storage
  2. token

其中:

  • storage = keyring 时,token 应为 None
  • storage = config 时,token 应有值

这让 secret 是否落盘变得显式、可推导。

8.5 AuthTokenStorage ​

目前只有两个枚举值:

  • Keyring
  • Config

这个枚举是把“secret 存在哪里”提升成显式模型的关键。

8.6 ResolvedAuth ​

表示解析到的“可用认证”:

  • token
  • source
  • username

这里的 username 不是永远都有:

  • 如果来源是环境变量,通常拿不到 username
  • 如果来源是本地账号,则通常会带上本地记录的 username

8.7 AuthLookup ​

这个枚举非常重要,因为它把“找不到认证”和“认证元数据存在但 token 无法读取”区分开了。

它有三种状态:

  1. Missing
  2. Found(ResolvedAuth)
  3. Unavailable(UnavailableStoredAuth)

这是 status 能做出更好诊断的基础。

8.8 LoginResult ​

用于表达 login 最终采用了哪种存储方式:

  • storage
  • used_fallback

这使得命令层可以准确输出:

  • 正常 keyring
  • 用户主动 --insecure-storage
  • 系统凭证存储失败导致的自动回退

8.9 AuthAccount ​

用于 auth switch 展示账号列表,包含:

  • username
  • storage
  • active

它是一个命令层友好的投影结构。

8.10 LogoutResult ​

表示 logout 后的结果:

  • 删除了哪个账号
  • 是否自动切到了另一个账号

这样命令层无需自己再推导后续提示。

9. 新配置格式与语义 ​

9.1 默认 keyring 场景 ​

toml
domain = "cnb.cool"
git_protocol = "https"

[auth."cnb.cool"]
user = "octocat"

[auth."cnb.cool".users.octocat]
storage = "keyring"

语义:

  • 当前域名 cnb.cool
  • 当前激活账号 octocat
  • octocat 的 token 实际在 keyring 中
  • 配置文件不存明文 token

9.2 fallback 到配置文件的场景 ​

toml
[auth."cnb.cool"]
user = "alice"

[auth."cnb.cool".users.alice]
storage = "config"
token = "cnb_xxxxxxxxxxxx"

语义:

  • 当前激活账号 alice
  • alice 的 token 保存在配置文件中
  • 原因可能是:
    • 用户显式用了 --insecure-storage
    • keyring 不可用时发生自动回退

9.3 同域名多账号场景 ​

toml
[auth."cnb.cool"]
user = "alice"

[auth."cnb.cool".users.alice]
storage = "config"
token = "cnb_xxxxxxxxxxxx"

[auth."cnb.cool".users.octocat]
storage = "keyring"

语义:

  • 同一个域名下保存了两个账号
  • 当前激活账号是 alice
  • alice 来自配置文件
  • octocat 来自 keyring

这说明 storage 是账号级别的,而不是域名级别的。

10. 旧配置兼容与迁移 ​

10.1 旧格式 ​

旧格式是:

toml
[auth.cnb.cool]
token = "cnb_xxxxxxxxxxxx"
username = "octocat"

10.2 为什么不能强制要求用户手工迁移 ​

如果强制要求用户先删掉旧配置再登录,会产生几个问题:

  1. 升级成本高
  2. 用户现有登录状态会瞬间失效
  3. 文档和版本切换时容易制造困惑

因此必须做兼容读取。

10.3 本次采用的迁移策略 ​

策略是:

  1. 读取时兼容旧字段
  2. 只有当该域名 auth 信息被修改时,才把旧结构提升成新结构
  3. 写回时只写新结构

这是一个“延迟迁移、按需写回”的策略。

10.4 优点 ​

  1. 不会因为执行一个纯读命令就重写配置文件
  2. 不会让旧用户无感丢失认证
  3. 实际发生写操作时,自然完成格式升级

10.5 潜在风险 ​

这个策略的主要前提是:

  • HostAuth::active_user
  • HostAuth::user_auth
  • HostAuth::migrate_legacy

三者的行为必须自洽

否则可能出现:

  • 旧配置能读但删不干净
  • 旧配置能读但切换账号时写错结构

本次已经在 config.rs 单测里覆盖了核心迁移路径。

11. 凭证存储层设计 ​

11.1 为什么要单独抽象 CredentialStore ​

如果命令层或 auth 业务层直接依赖 keyring crate,会有几个问题:

  1. 测试难写
  2. 平台差异会污染上层逻辑
  3. 错误语义无法统一
  4. 以后如果想替换存储实现,改动面太大

因此这次特意引入了 trait:

rust
pub trait CredentialStore {
    fn get(&self, service: &str, account: &str) -> Result<String, CredentialError>;
    fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), CredentialError>;
    fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
}

11.2 SystemCredentialStore ​

这是默认实现,底层走 keyring crate,但外部不直接感知。

11.3 3 秒超时策略 ​

为什么要做超时保护:

  1. 某些桌面环境下 secret service 可能不可用
  2. 某些 keyring 调用可能出现异常阻塞
  3. CLI 认证命令不能因为桌面凭证服务问题长时间卡住

因此统一做法是:

  • 把实际 keyring 操作放到线程里
  • 主线程等待 recv_timeout
  • 超时则返回 CredentialError::Timeout

11.4 service / account 命名策略 ​

采用:

  • service: cnb-rs:{domain}
  • account: {username}

理由:

  1. service 与产品名绑定
  2. 同一域名下多账号天然区分
  3. 不需要额外维护“激活 token 槽位”

11.5 错误收敛 ​

对上层来说,不需要知道底层到底是:

  • keyring backend 缺失
  • secret service 关闭
  • Windows 凭证管理器异常
  • 当前账号没存过

业务层只需要感知:

  • 没有凭证
  • 超时
  • 凭证服务不可用

这就是 CredentialError 的价值。

12. Token 解析算法 ​

这是整个模型中最关键的一段逻辑。

12.1 解析优先级 ​

严格顺序如下:

  1. CNB_TOKEN_{DOMAIN}
  2. CNB_TOKEN
  3. 当前激活账号的 keyring token
  4. 当前激活账号的 config token

12.2 为什么环境变量必须优先 ​

因为环境变量最适合:

  • CI
  • 临时脚本
  • 无状态运行

一旦环境变量存在,用户通常期望的是“这次运行强制使用它”,而不是和本地存储混用。

12.3 为什么不直接 fallback 成 Missing ​

在 keyring 模式下,如果本地元数据仍在,但 token 不可读取:

  • 直接返回 Missing 会让 status 看起来像“什么都没有”
  • 这会掩盖问题根因

所以这里引入了 AuthLookup::Unavailable,用于表达:

  • 用户确实保存过账号
  • 但当前 secret 不可读

12.4 伪代码 ​

text
resolve_auth(domain, config):
  if domain-specific env token exists:
    return Found(env token, env-domain)

  if generic env token exists:
    return Found(env token, env-generic)

  host = config.host_auth(domain)
  if host missing:
    return Missing

  username = host.active_user()
  if username missing:
    return Missing

  user_auth = host.user_auth(username)
  if missing:
    return Missing

  if user_auth.storage == Keyring:
    try keyring.get(service, username)
      success -> Found(token, Keyring)
      error   -> Unavailable(username, Keyring, error)

  if user_auth.storage == Config:
    if token exists and non-empty:
      return Found(token, ConfigFile)
    else:
      return Unavailable(username, ConfigFile, "配置文件中未找到 token")

13. auth login 执行路径 ​

13.1 输入处理 ​

auth login 支持三种输入源:

  1. --token
  2. --with-token
  3. 交互式隐藏输入

命令层先收敛成一个最终字符串 token,再交给业务层。

13.2 环境变量保护 ​

在真正处理 login 之前,先检查当前域名是否已经被环境变量接管。

如果接管中:

  • 命令直接失败
  • 不去写 keyring
  • 不去写 config

原因是:

  • 即使本地写成功,真正运行时仍然优先命中环境变量
  • 这会制造“我明明登录成功了但为什么还是另一个 token 在生效”的困惑

13.3 token 校验 ​

当前实现仍然保留原来的 /user 校验逻辑:

  1. 构造临时 CnbClient
  2. 调 me()
  3. 通过返回值获得 username

这一步是非常重要的,因为:

  • keyring / config 的持久化必须以有效 token 为前提
  • username 是后续多账号模型的主键之一

13.4 持久化策略 ​

进入 persist_login(domain, token, username, insecure_storage) 后,分成两条主路径。

路径 A:显式 --insecure-storage ​

直接:

  1. config.upsert_config_auth
  2. config.save
  3. 返回 LoginStorage::ConfigFile

路径 B:默认安全存储 ​

  1. 先 keyring.set
  2. 成功后更新配置元数据为 keyring 模式
  3. 再写回配置

13.5 为什么先写 keyring 再写 config ​

原因:

  1. 如果先写 config 再写 keyring,可能会留下错误元数据
  2. keyring 成功是默认分支的前提
  3. config 只是描述“这个账号的 token 在哪”

13.6 config 写回失败时的回滚 ​

这是一个很关键但容易被忽视的细节。

在默认 keyring 路径下,顺序是:

  1. keyring.set 成功
  2. config.save 失败

这时如果不处理,会出现:

  • keyring 里已经有 token
  • 但配置文件里没有对应元数据

后果是:

  • token 实际存在
  • CLI 却无法解析到它

因此现在的行为是:

  1. config.save 失败时尝试删除刚写进去的 keyring 记录
  2. 如果回滚失败,把回滚错误拼接到原错误上

这个设计很重要,它保证了默认路径尽量具备事务一致性。

13.7 keyring 失败的 fallback ​

如果 keyring.set 返回:

  • Timeout
  • Unavailable

则自动:

  1. 改写为 config 模式
  2. 明文保存 token
  3. 返回 used_fallback = true

命令层再根据这个结果输出告警。

14. auth status 执行路径 ​

14.1 第一步:解析 token 来源 ​

命令层先调用:

  • auth::resolve_auth(domain, config)

这会返回三类结果。

14.2 Missing ​

表示:

  • 环境变量里没有
  • 本地配置里也没有能用的认证

这时输出:

  • 未登录
  • 提示用户用 auth login 或环境变量

14.3 Unavailable ​

表示:

  • 本地配置中有当前账号元数据
  • 但 token 无法读取

典型场景:

  • keyring 服务异常
  • keyring 中对应记录已经丢失
  • config 模式下 token 字段空了

这时 status 不会假装未登录,而是直接告诉用户:

  • 记录中的用户名
  • 来源类型
  • 具体错误

14.4 Found ​

表示 token 可用。

这时命令层做两件事:

  1. 掩码显示 token
  2. 再用 API 去校验用户名

14.5 为什么还要再次调用 /user ​

因为本地保存的 username 只是“上次登录时记录的用户名”,不等同于“当前 token 一定有效”。

所以:

  • 本地 username 用于 fallback
  • API username 用于确认 token 真实仍有效

14.6 用户名回退策略 ​

如果 API 校验失败:

  • 不直接显示 (Token 无效) 覆盖全部信息
  • 而是优先保留本地保存的 username
  • 同时额外输出状态提示

这样信息量更完整。

15. auth logout 执行路径 ​

15.1 为什么先看环境变量 ​

如果当前认证来自环境变量,本地删除任何 keyring / config 信息都不会影响本次运行的认证结果。

所以此时正确行为不是“继续删本地”,而是:

  • 明确告诉用户 token 来源是环境变量
  • 提示用户手动 unset

15.2 本地账号退出流程 ​

当当前来源不是环境变量时,logout 调用:

  • auth::logout(domain)

业务层逻辑为:

  1. 找到当前激活账号
  2. 如果其 storage 是 keyring,则删除 keyring 记录
  3. 删除配置中的该账号元数据
  4. 如果还有剩余账号,切换 active user
  5. 写回配置

15.3 自动切换剩余账号 ​

这是多账号语义中很关键的一步。

如果当前账号被删掉,而域名下还有其他账号,那么:

  • 不应该让 host 落入“有 users 但没有 active user”的中间态

因此现在直接自动切换到剩余账号中的第一个。

15.4 为什么不在命令层做自动切换 ​

因为自动切换是认证模型本身的一部分,不是 UI 行为。

如果把它放到命令层,会导致:

  • 以后其他命令要复用 logout 逻辑时重复实现
  • 行为容易分叉

所以这部分逻辑被放在 cnb-core::auth。

16. auth switch 执行路径 ​

16.1 为什么需要单独的 auth switch ​

有了多账号之后,如果没有切换命令,用户只能靠:

  • 再次 login 覆盖当前账号

这会导致:

  • 语义不清晰
  • 多账号功能名存实亡

因此 auth switch 是多账号模型成立的必要配套。

16.2 参数式与交互式双模式 ​

现在支持:

  • cnb-rs auth switch octocat
  • cnb-rs auth switch

不带用户名时会列出账号,提示输入编号或用户名。

16.3 切换前校验 ​

为什么要在切换前校验目标账号 token 是否可读:

如果直接只改 active user,会产生一种坏状态:

  • 当前激活账号指到了一个根本读不到 token 的账号

这会导致:

  • switch 看起来成功
  • 但后续命令立刻失败

所以切换前会先校验:

  • keyring 模式下 get 能成功
  • config 模式下 token 字段非空

16.4 环境变量保护 ​

和 login 一样,环境变量生效时拒绝切换本地账号。

原因同样是:

  • 切换成功也不会影响当前运行态的真正 token 来源

17. 关键边界场景 ​

下面列出这次设计重点覆盖的边界场景。

17.1 当前认证来自环境变量,再执行 auth login ​

预期:

  • 直接拒绝
  • 提示先清理环境变量
  • 不写 keyring
  • 不写 config

17.2 keyring 不可用,但用户正常执行 auth login ​

预期:

  • 登录流程仍然成功
  • 自动回退到 config
  • 清晰警告这是 fallback,不是默认行为

17.3 keyring 中 token 丢了,但配置里还保留 active user ​

预期:

  • auth status 显示“token 无法读取”
  • 不是显示“未登录”

17.4 删除当前账号后还有其他账号 ​

预期:

  • 自动切换到剩余账号
  • 提示切换结果

17.5 切换到一个 keyring 记录已丢失的账号 ​

预期:

  • 切换失败
  • 不修改 active user

17.6 config 保存失败但 keyring 已写成功 ​

预期:

  • 尝试回滚 keyring
  • 避免留下“secret 有了但 metadata 没有”的脏状态

18. 测试设计 ​

18.1 为什么主要是单元测试 ​

因为这次的难点主要不在:

  • HTTP 通讯
  • 终端渲染
  • 跨平台 GUI 交互

而在于:

  • 配置模型
  • 解析顺序
  • fallback 语义
  • 多账号状态变换

这些更适合做细粒度单测。

18.2 auth.rs 已覆盖的核心测试点 ​

  1. 环境变量优先于其他来源
  2. keyring 优先于配置文件
  3. keyring 不可读时返回 Unavailable
  4. 旧版单账号配置可以被识别为本地账号

18.3 config.rs 已覆盖的核心测试点 ​

  1. legacy_token + legacy_username 可迁移为新结构
  2. switch_auth_user 正确更新 active user
  3. 删除当前账号后正确切换到剩余账号

18.4 为什么使用内存 fake store ​

因为真实 keyring 测试会带来:

  • 平台依赖
  • 桌面服务依赖
  • CI 不稳定

而 fake store 足以验证绝大多数 auth 业务逻辑。

19. 本次运行过的验证 ​

已经执行:

  • cargo fmt --all
  • cargo check
  • cargo test
  • cargo test -p cnb-core --lib

结果:

  • 全部通过

另外执行过:

  • cargo clippy --all-targets --all-features -- -D warnings

结果:

  • 这次 auth 改动本身没有新引入阻塞性 clippy 问题
  • 但仓库中已有两处无关旧问题仍会让全量 clippy 失败:
    • cnb/src/commands/stats.rs
    • cnb/src/commands/version.rs

这两处不是这次改造引入的,因此本次未顺手修改。

20. 提交拆分与意图 ​

这次改造拆成了四个本地提交,每个提交都有明确边界。

20.1 07a3895 ​

📦 build(auth): 接入系统凭证存储依赖

意图:

  • 只引入 keyring 能力和底层抽象
  • 不动认证模型和命令行为

20.2 95ffa87 ​

♻️ refactor(auth): 重构认证配置模型与凭证解析

意图:

  • 只重构核心数据结构与解析逻辑
  • 不引入新的 CLI 用户入口

20.3 b4f192b ​

✨ feat(auth): 对齐 gh 登录存储与账号切换行为

意图:

  • 落用户可见的命令行为变化
  • 新增 auth switch

20.4 95e29cc ​

📝 docs(auth): 更新认证文档为 keyring 与多账号模型

意图:

  • 只同步文档,不混代码

21. 当前仍保留的差异 ​

虽然已经尽量对齐 gh,但仍有几个差异值得明确记录。

21.1 没有 web/device flow ​

gh 的登录主路径偏向浏览器授权流。

cnb-rs 当前仍是:

  1. PAT 输入
  2. /user 校验
  3. 本地保存

这次不引入浏览器登录,是刻意缩小范围,避免把认证协议层和存储层变更混在一次改造里。

21.2 没有 host-level active token 快捷槽位 ​

gh 为了配合自身配置读取逻辑,会在 keyring 中保留“当前激活 token 快捷槽位”。

cnb-rs 当前没有这层。

影响:

  • 不影响当前功能
  • 但意味着如果未来要做外部工具复用 cnb-rs keyring 数据,就需要走配置中的 active user

21.3 没有真实 keyring 集成测试 ​

当前主要是纯逻辑单测。

这足以验证模型正确性,但还不能完全替代:

  • Windows 实机
  • Linux Secret Service
  • macOS Keychain

的真实行为验证。

22. 风险与观察点 ​

这次改造整体已经稳定,但仍有一些值得后续观察的点。

22.1 不同平台 keyring 的可用性差异 ​

尤其是 Linux 上,用户环境差异很大:

  • 有些有桌面 session
  • 有些是纯终端
  • 有些 secret service 没启动

因此 fallback 分支很可能是 Linux 用户最常走到的路径之一。

22.2 配置迁移是否会遇到更多历史格式 ​

当前兼容的是已知旧格式:

  • token
  • username

如果历史上曾存在更多非正式结构,后续可能还需要额外补兼容。

22.3 多账号下未来是否要增加 auth list ​

现在虽然已经能 switch,但还没有一个纯查看本地账号列表的命令。

长期来看,auth list 会让多账号模型更完整。

22.4 status 是否要支持“离线快速模式” ​

当前 status 会尝试联网校验 /user。

未来可能可以考虑:

  • auth status --offline

或者默认快速显示本地信息,再按需联网校验。

23. 后续建议 ​

如果下一步继续完善 auth 体系,我建议优先顺序如下。

23.1 第一优先级 ​

补真实 keyring 集成测试,至少覆盖:

  • Windows
  • Linux

23.2 第二优先级 ​

增加:

  • auth list

作用:

  • 明确展示当前域名下所有已保存账号
  • 减少用户通过 auth switch 才能看到账号列表的绕路

23.3 第三优先级 ​

评估是否引入浏览器授权流。

如果做这一步,需要再次审视:

  • 登录结果结构
  • token 来源文案
  • 是否需要增加更接近 gh 的 active token 槽位

23.4 第四优先级 ​

清理仓库里的既有 clippy 旧问题,避免以后每次大改 auth 都要额外解释“为什么 clippy 没全绿”。

24. 审查建议 ​

如果您打算仔细 review,我建议重点看下面几个点。

24.1 模型正确性 ​

重点看:

  • HostAuth
  • UserAuth
  • AuthLookup

这三个结构是否足以支撑未来 1 到 2 个版本内的认证需求。

24.2 事务一致性 ​

重点看 login 默认路径里:

  • 先 keyring.set
  • 再 config.save
  • 失败后回滚 keyring

这条链路是否还有遗漏状态。

24.3 多账号删除与切换 ​

重点看:

  • remove_auth_user
  • switch_auth_user
  • logout_with_store
  • switch_user_with_store

是否可能留下:

  • users 还在但 active user 丢失
  • active user 指向不存在账号

24.4 环境变量保护语义 ​

重点确认:

  • login 拒绝覆盖本地
  • switch 拒绝切换本地
  • logout 仅提示 unset

这三者是否符合您对最终 UX 的预期。

25. 总结 ​

这次改造的本质,是把 cnb-rs 的认证体系从:

  • 单账号
  • 配置文件明文 token
  • 没有 keyring
  • 没有激活账号概念

升级成了:

  • 环境变量优先
  • 默认 keyring
  • 失败自动 fallback
  • 多账号
  • 激活账号显式化
  • logout / switch / status 语义闭环

从用户角度,最直接的收益是:

  1. 默认更安全
  2. 同域名多账号可用
  3. status 更可诊断
  4. logout 不再只会粗暴删配置

从工程角度,最重要的收益是:

  1. 认证存储与配置元数据解耦
  2. 命令层与业务层职责更清晰
  3. 后续继续扩展 auth 能力时,不需要再推倒重来