跳转到内容

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 的默认安全策略
  • 结构上无法自然扩展到多账号
  • 没有“当前激活账号”的显式概念
  • statuslogout 的语义与未来 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 能力时,不需要再推倒重来