搜索 K
主题
本文档不是简单的变更摘要,而是一份偏设计说明性质的记录,目标是把这次 cnb-rs auth 改造讲清楚,方便您后续从下面几个角度审视:
gh auth 的对齐程度是否足够本文既适合代码审查,也适合作为后续继续演进认证体系时的上下文文档。
在改造之前,cnb-rs 的认证体系本质上是一个“单域名单账号 + 配置文件明文 token”的模型:
auth login 验证 token 后,直接写入 ~/.cnb/config.tomlauth status 仅识别“环境变量”或“配置文件”两类来源auth logout 只会删除配置文件中的该域名 token这个模型的问题很明显:
gh 的默认安全策略status 和 logout 的语义与未来 keyring 模型不兼容而 gh auth login 的用户心智已经比较成熟:
因此,这次改造的目标不是“给 auth login 加个 keyring”,而是把整个认证模型升级到接近 gh 的层级。
这次改造的目标可以分成功能目标、体验目标和工程目标三类。
status 能准确区分 token 来源logout 能按当前激活账号退出,而不是粗暴删除整个域名配置gh这次改造有意没有覆盖以下内容:
auth listauth token 之类的敏感输出命令gh 的“host-level active token 空用户名槽位”模型这些不是遗漏,而是有意控制范围,先把当前最关键的认证地基打稳。
本次参考的是工作区里的 cli/ 仓库,也就是 GitHub CLI 的实现。
重点参考了 gh 的以下几个行为:
auth login 默认走系统凭证存储gh 在 keyring 中会保留两类记录:
service = gh:<host>, account = <username>service = gh:<host>, account = ""第二类实际上是“当前激活 token”的快捷槽位。
cnb-rs 没有照搬这一层,而是采用:
service = cnb-rs:<domain>, account = <username>user 字段表示这样做的原因:
cnb-rs 自己控制认证解析,不需要兼容 go-gh 的接口因此,这次是“行为对齐优先,内部实现不盲目复制”。
改造后,认证系统被拆成三层。
配置层只负责保存“账号元数据”,例如:
配置层不再默认承担 secret 存储职责。
新增了一个独立的系统凭证存储抽象,用于封装平台相关的 keyring 访问:
这一层对外暴露统一错误语义,并负责超时保护。
认证业务层负责业务决策:
命令层只负责:
下面按文件说明职责边界。
cnb/crates/cnb-core/src/credential_store.rs 职责:
它是平台适配层,不负责 auth 策略。
cnb/crates/cnb-core/src/config.rs 职责:
它负责“数据结构与持久化”,不负责命令行为。
cnb/crates/cnb-core/src/auth.rs 职责:
它是整个认证模型的核心。
cnb/src/commands/auth/login.rs 职责:
/user 校验 tokenpersist_loginLoginResult 决定是否输出 fallback 警告cnb/src/commands/auth/status.rs 职责:
resolve_authAuthLookup 的不同分支显示状态cnb/src/commands/auth/logout.rs 职责:
auth::logoutcnb/src/commands/auth/switch.rs 职责:
auth::switch_user这次改造最关键的不是命令,而是数据结构。
Config 仍然是整个配置文件的顶层结构,保留原有的:
domaingit_protocolauth其中 auth 是这次改造的主要变化点。
AuthConfigToml 本质是:
pub struct AuthConfigToml {
pub hosts: BTreeMap<String, HostAuth>,
}也就是以域名为 key,映射到某个域名下的认证配置。
选择 BTreeMap 而不是 HashMap 的一个好处是:
HostAuth 这是整个模型里最关键的结构之一。
它现在包含四部分信息:
user 当前激活账号users 当前域名下的所有账号legacy_token 兼容旧版单账号明文 tokenlegacy_username 兼容旧版单账号用户名也就是说,它既能表达新的多账号模型,也能在读取时容忍旧格式。
UserAuth 每个账号一条记录,包含:
storagetoken其中:
storage = keyring 时,token 应为 Nonestorage = config 时,token 应有值这让 secret 是否落盘变得显式、可推导。
AuthTokenStorage 目前只有两个枚举值:
KeyringConfig这个枚举是把“secret 存在哪里”提升成显式模型的关键。
ResolvedAuth 表示解析到的“可用认证”:
这里的 username 不是永远都有:
AuthLookup 这个枚举非常重要,因为它把“找不到认证”和“认证元数据存在但 token 无法读取”区分开了。
它有三种状态:
MissingFound(ResolvedAuth)Unavailable(UnavailableStoredAuth)这是 status 能做出更好诊断的基础。
LoginResult 用于表达 login 最终采用了哪种存储方式:
storageused_fallback这使得命令层可以准确输出:
--insecure-storageAuthAccount 用于 auth switch 展示账号列表,包含:
它是一个命令层友好的投影结构。
LogoutResult 表示 logout 后的结果:
这样命令层无需自己再推导后续提示。
domain = "cnb.cool"
git_protocol = "https"
[auth."cnb.cool"]
user = "octocat"
[auth."cnb.cool".users.octocat]
storage = "keyring"语义:
cnb.cooloctocatoctocat 的 token 实际在 keyring 中[auth."cnb.cool"]
user = "alice"
[auth."cnb.cool".users.alice]
storage = "config"
token = "cnb_xxxxxxxxxxxx"语义:
alicealice 的 token 保存在配置文件中--insecure-storage[auth."cnb.cool"]
user = "alice"
[auth."cnb.cool".users.alice]
storage = "config"
token = "cnb_xxxxxxxxxxxx"
[auth."cnb.cool".users.octocat]
storage = "keyring"语义:
alicealice 来自配置文件octocat 来自 keyring这说明 storage 是账号级别的,而不是域名级别的。
旧格式是:
[auth.cnb.cool]
token = "cnb_xxxxxxxxxxxx"
username = "octocat"如果强制要求用户先删掉旧配置再登录,会产生几个问题:
因此必须做兼容读取。
策略是:
这是一个“延迟迁移、按需写回”的策略。
这个策略的主要前提是:
HostAuth::active_userHostAuth::user_authHostAuth::migrate_legacy三者的行为必须自洽
否则可能出现:
本次已经在 config.rs 单测里覆盖了核心迁移路径。
CredentialStore 如果命令层或 auth 业务层直接依赖 keyring crate,会有几个问题:
因此这次特意引入了 trait:
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>;
}SystemCredentialStore 这是默认实现,底层走 keyring crate,但外部不直接感知。
为什么要做超时保护:
因此统一做法是:
recv_timeoutCredentialError::Timeout采用:
cnb-rs:{domain}{username}理由:
对上层来说,不需要知道底层到底是:
业务层只需要感知:
这就是 CredentialError 的价值。
这是整个模型中最关键的一段逻辑。
严格顺序如下:
CNB_TOKEN_{DOMAIN}CNB_TOKEN因为环境变量最适合:
一旦环境变量存在,用户通常期望的是“这次运行强制使用它”,而不是和本地存储混用。
在 keyring 模式下,如果本地元数据仍在,但 token 不可读取:
status 看起来像“什么都没有”所以这里引入了 AuthLookup::Unavailable,用于表达:
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")auth login 执行路径 auth login 支持三种输入源:
--token--with-token命令层先收敛成一个最终字符串 token,再交给业务层。
在真正处理 login 之前,先检查当前域名是否已经被环境变量接管。
如果接管中:
原因是:
当前实现仍然保留原来的 /user 校验逻辑:
CnbClientme()这一步是非常重要的,因为:
进入 persist_login(domain, token, username, insecure_storage) 后,分成两条主路径。
--insecure-storage 直接:
config.upsert_config_authconfig.saveLoginStorage::ConfigFilekeyring.set原因:
这是一个很关键但容易被忽视的细节。
在默认 keyring 路径下,顺序是:
这时如果不处理,会出现:
后果是:
因此现在的行为是:
config.save 失败时尝试删除刚写进去的 keyring 记录这个设计很重要,它保证了默认路径尽量具备事务一致性。
如果 keyring.set 返回:
TimeoutUnavailable则自动:
used_fallback = true命令层再根据这个结果输出告警。
auth status 执行路径 命令层先调用:
auth::resolve_auth(domain, config)这会返回三类结果。
Missing 表示:
这时输出:
auth login 或环境变量Unavailable 表示:
典型场景:
这时 status 不会假装未登录,而是直接告诉用户:
Found 表示 token 可用。
这时命令层做两件事:
/user 因为本地保存的 username 只是“上次登录时记录的用户名”,不等同于“当前 token 一定有效”。
所以:
如果 API 校验失败:
(Token 无效) 覆盖全部信息这样信息量更完整。
auth logout 执行路径 如果当前认证来自环境变量,本地删除任何 keyring / config 信息都不会影响本次运行的认证结果。
所以此时正确行为不是“继续删本地”,而是:
当当前来源不是环境变量时,logout 调用:
auth::logout(domain)业务层逻辑为:
这是多账号语义中很关键的一步。
如果当前账号被删掉,而域名下还有其他账号,那么:
因此现在直接自动切换到剩余账号中的第一个。
因为自动切换是认证模型本身的一部分,不是 UI 行为。
如果把它放到命令层,会导致:
所以这部分逻辑被放在 cnb-core::auth。
auth switch 执行路径 auth switch 有了多账号之后,如果没有切换命令,用户只能靠:
这会导致:
因此 auth switch 是多账号模型成立的必要配套。
现在支持:
cnb-rs auth switch octocatcnb-rs auth switch不带用户名时会列出账号,提示输入编号或用户名。
为什么要在切换前校验目标账号 token 是否可读:
如果直接只改 active user,会产生一种坏状态:
这会导致:
所以切换前会先校验:
get 能成功和 login 一样,环境变量生效时拒绝切换本地账号。
原因同样是:
下面列出这次设计重点覆盖的边界场景。
auth login 预期:
auth login 预期:
预期:
auth status 显示“token 无法读取”预期:
预期:
预期:
因为这次的难点主要不在:
而在于:
这些更适合做细粒度单测。
auth.rs 已覆盖的核心测试点 Unavailableconfig.rs 已覆盖的核心测试点 legacy_token + legacy_username 可迁移为新结构switch_auth_user 正确更新 active user因为真实 keyring 测试会带来:
而 fake store 足以验证绝大多数 auth 业务逻辑。
已经执行:
cargo fmt --allcargo checkcargo testcargo test -p cnb-core --lib结果:
另外执行过:
cargo clippy --all-targets --all-features -- -D warnings结果:
cnb/src/commands/stats.rscnb/src/commands/version.rs这两处不是这次改造引入的,因此本次未顺手修改。
这次改造拆成了四个本地提交,每个提交都有明确边界。
07a3895 📦 build(auth): 接入系统凭证存储依赖
意图:
95ffa87 ♻️ refactor(auth): 重构认证配置模型与凭证解析
意图:
b4f192b ✨ feat(auth): 对齐 gh 登录存储与账号切换行为
意图:
auth switch95e29cc 📝 docs(auth): 更新认证文档为 keyring 与多账号模型
意图:
虽然已经尽量对齐 gh,但仍有几个差异值得明确记录。
gh 的登录主路径偏向浏览器授权流。
cnb-rs 当前仍是:
/user 校验这次不引入浏览器登录,是刻意缩小范围,避免把认证协议层和存储层变更混在一次改造里。
gh 为了配合自身配置读取逻辑,会在 keyring 中保留“当前激活 token 快捷槽位”。
cnb-rs 当前没有这层。
影响:
当前主要是纯逻辑单测。
这足以验证模型正确性,但还不能完全替代:
的真实行为验证。
这次改造整体已经稳定,但仍有一些值得后续观察的点。
尤其是 Linux 上,用户环境差异很大:
因此 fallback 分支很可能是 Linux 用户最常走到的路径之一。
当前兼容的是已知旧格式:
tokenusername如果历史上曾存在更多非正式结构,后续可能还需要额外补兼容。
auth list 现在虽然已经能 switch,但还没有一个纯查看本地账号列表的命令。
长期来看,auth list 会让多账号模型更完整。
status 是否要支持“离线快速模式” 当前 status 会尝试联网校验 /user。
未来可能可以考虑:
auth status --offline或者默认快速显示本地信息,再按需联网校验。
如果下一步继续完善 auth 体系,我建议优先顺序如下。
补真实 keyring 集成测试,至少覆盖:
增加:
auth list作用:
auth switch 才能看到账号列表的绕路评估是否引入浏览器授权流。
如果做这一步,需要再次审视:
gh 的 active token 槽位清理仓库里的既有 clippy 旧问题,避免以后每次大改 auth 都要额外解释“为什么 clippy 没全绿”。
如果您打算仔细 review,我建议重点看下面几个点。
重点看:
HostAuthUserAuthAuthLookup这三个结构是否足以支撑未来 1 到 2 个版本内的认证需求。
重点看 login 默认路径里:
这条链路是否还有遗漏状态。
重点看:
remove_auth_userswitch_auth_userlogout_with_storeswitch_user_with_store是否可能留下:
重点确认:
这三者是否符合您对最终 UX 的预期。
这次改造的本质,是把 cnb-rs 的认证体系从:
升级成了:
从用户角度,最直接的收益是:
从工程角度,最重要的收益是: