ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kiln 桌面端 GitHub OAuth Token 自动续期设计解析:从 8 小时失效到 6 个月免干预同步

Kiln 桌面端 GitHub OAuth Token 自动续期设计解析:从 8 小时失效到 6 个月免干预同步
  • 人工智能
  • AI 应用
  • AI 评测
  • RAG
  • 微调
  • 提示工程
  • 开发工具
  • 桌面应用

【免费下载链接】Kiln

Build, Evaluate, and Optimize AI Systems. Includes evals, RAG, agents, fine-tuning, synthetic data generation, dataset management, MCP, and more.

项目地址:https://gitcode.com/gh_mirrors/ki/Kiln
点击查看免费下载

本文基于 Kiln 仓库中 GitHub OAuth Token Refresh 功能规格(status: complete),结合app/desktop/git_sync/与libs/core/kiln_ai/utils/config.py的现有源码,系统讲解 Kiln 如何捕获、持久化并自动续期 GitHub App 的用户访问令牌(user access token),使 Git 同步在约 6 个月的 refresh token 生命周期内无需用户干预持续工作。读完本文,你将掌握 OAuth 令牌捕获、主动/被动双通道续期、令牌轮换持久化、错误分类(401 vs 503)以及并发串行化等完整设计方案,并能对照当前仓库源码识别"问题现状"与"目标设计"之间的差异。

一、背景与问题根因:OAuth 访问令牌为何会在 8 小时后集体失效

Kiln 的 Git 同步(Git Sync)完全运行在桌面端客户端中(app/desktop/git_sync/),底层使用 pygit2 执行 fetch/push。对于通过Kiln AI GitHub SyncGitHub App 连接的项目(auth_mode="github_oauth"),OAuth 用户访问令牌被用作 git HTTPS 密码:

# app/desktop/git_sync/clone.py 中 make_credentials() 的 github_oauth 分支 if allowed_types & pygit2.enums.CredentialType.USERPASS_PLAINTEXT: return pygit2.UserPass(username="x-token", password=token)

问题在于:当 GitHub App 开启"Expire user authorization tokens"(默认行为)后,用户访问令牌是短时效的——GitHub 默认在 8 小时左右使其失效,但同时会返回一个有效期约6 个月的refresh_token与expires_in,供客户端静默续期。而 Kiln 当时把这一切都丢弃了,具体根因有三点,均可在当前仓库源码中逐一印证:

  1. token 交换时只取 access_token:当前 oauth.py 的exchange_code_for_token()只返回data["access_token"],refresh_token、expires_in、refresh_token_expires_in全部被丢弃。同时 OAuthFlowState 也只保存oauth_token,没有任何过期时间或 refresh token 字段。
  2. 配置模型没有存储位:GitSyncProjectConfig(TypedDict)仅有pat_token与oauth_token,无法保存 refresh token 或过期时间戳。
  3. 全代码库没有续期逻辑:整个仓库中不存在grant_type=refresh_token的调用,也没有任何过期检查(本文所述设计的所有相关标识符目前仅存在于 specs/projects/git_oauth_token_refresh/ 的规格文档中)。

由此产生的直接后果是:当访问令牌过期后,pygit2 的凭据回调被远端拒绝,抛出pygit2.GitError("Authentication failed..."),而 GitSyncManager.fetch() 会把认证错误包装成RemoteUnreachableError,最终经 middleware.py 的 ERROR_MAP 映射为HTTP 503与误导性的"Cannot sync with remote. Check your connection."。用户只能手动重新连接才能恢复——这正是规格文档要解决的"认证过期被误报为网络问题"的核心矛盾。

二、目标与非目标

2.1 三大目标

  1. 捕获并持久化:用户通过 OAuth 连接/重连时,将 GitHub 返回的refresh_token与令牌过期时间戳一并保存。
  2. 自动续期:在 refresh token 生命周期(约 6 个月)内,让 OAuth 访问令牌自动保持有效,零用户操作。
  3. 准确分类错误:当令牌确实无法续期时,给出清晰、分类正确的"重新连接 GitHub"错误,而非含糊的"检查网络"。

2.2 明确不做的事

  • 不改动 PAT 与 SSH 认证流程(pat_token、system_keys用户不受影响)。
  • 不提供恢复 UI,也不修复任务选择器的[Object object]渲染问题——那属于独立的 git_auth_recovery 项目,该项目消费本设计产出的 401AuthExpiredError。
  • 不做 server-to-server(installation token)认证:本设计保持用户访问令牌(OAuth)流程不变,仅新增续期环节。

三、核心行为设计:B1–B7 七个行为

B1. 令牌捕获(连接与重连)

当exchange_code_for_token()成功时,从 GitHub 响应中捕获以下字段,并在交换时刻于服务端计算为绝对时间戳:

  • access_token
  • refresh_token(可能缺失,见 B6)
  • expires_in→ 换算为绝对时间oauth_token_expires_at(epoch 秒,墙钟时间)
  • refresh_token_expires_in→ 换算为绝对时间oauth_refresh_token_expires_at

这些字段沿两条既有路径落库:新建连接走 OAuth 状态 → 向导 →save_config路径;重连走PATCH /config路径(对应 git_sync_api.py 的api_update_config)。最终统一持久化在GitSyncProjectConfig中。

B2. 主动续期(每次远端操作前)

对于github_oauth项目,任何 fetch/push 之前执行检查:若now >= oauth_token_expires_at - REFRESH_SKEW(skew 取 300 秒,即到期前 5 分钟),则用 refresh token 发起续期、持久化新令牌,并以新 access token 执行本次操作。若令牌仍有效(或过期时间未知,见 B6),则什么都不做。该逻辑在设计中收敛为GitSyncManager._ensure_valid_oauth_token()方法,在fetch()与commit_and_push()的 push 之前调用。

B3. 被动续期(认证被拒时兜底)

当 fetch/push 因认证拒绝(而非网络错误)失败,且该项目有 refresh token、本次操作尚未尝试过续期时:无视 skew 强制续期一次并重试该操作。这覆盖了主动检查遗漏的两种情况——本地时钟偏移(clock skew)与服务端令牌提前失效(如令牌被吊销或 GitHub 内部轮换)。

B4. 刷新令牌轮换

GitHub 在每次续期时都会轮换 refresh token:响应中携带一个全新的refresh_token,旧 token 随即失效。因此设计强制要求:

  • 每次续期成功立即持久化新 refresh token 与新过期时间;
  • 只在成功响应后落库;失败时保持已存储凭据原样不动,绝不写半成品状态。

B5. 正确的错误分类

这是本文档的核心价值之一,把"认证过期"与"网络故障"彻底分开:

场景异常HTTP提示语
认证无法续期(refresh token 缺失/过期、续期请求返回 OAuth 错误、pygit2 报告凭据被拒且被动续期也失败)AuthExpiredError(新增)401"GitHub authorization expired. Reconnect your GitHub account in the project's Git Sync settings."
真正的网络/远端故障(超时、DNS、连接拒绝、非认证类pygit2.GitError)RemoteUnreachableError(保留)503"Cannot sync with remote. Check your connection."(不变)

B6. 无过期时间的令牌(App 设置关闭 / 旧版)

若 GitHub 未返回expires_in与refresh_token(即 App 的 "Expire user authorization tokens" 选项关闭),说明 access token 是长效的:此时存储oauth_token_expires_at = None、oauth_refresh_token = None,主动续期为 no-op,令牌原样使用,同步无限期正常工作。

规格文档特别给出一个运维提示:要让续期路径真正生效,GitHub App 必须开启 "Expire user authorization tokens"(GitHub App 设置 → Optional features)。生产 App 几乎必然已开启——因为正是该选项才制造了 8 小时失效这个 bug——但"应该验证,而非假设",这一点被明确列入验证清单。

B7. 存量 OAuth 用户迁移

现有配置只有oauth_token,没有 refresh token 与过期时间(expires_at = None)。设计行为是:继续使用存量令牌直到它失败;失败时直接报AuthExpiredError(401 重新连接提示),因为此时无 refresh token 可用。用户重连后,新令牌包含 refresh token,此后自动续期便正常工作。无需编写任何迁移脚本。

四、边界情况与错误处理策略

  • 无 refresh token 但令牌已过期(旧版用户或关闭过期选项的用户):直接AuthExpiredError,不进入死循环重试。
  • 续期请求本身网络失败:优先以AuthExpiredError呈现(提示用户重连),而非无限静默重试——因为 refresh token 本身未经验证,无法干净地区分"GitHub 宕机"与"refresh token 已死",选择更具可操作性的重连提示是可接受的权衡(文档明确记录了这一取舍)。
  • 并发续期:后台同步(每 10s 一轮)与前台请求可能同时撞上过期点。续期必须按仓库串行化 + 双重检查过期状态,确保单次使用的 refresh token 只被消费一次。
  • 后台同步的认证失败:记录清晰日志并保持"下次轮询重试"的既有行为,后台同步绝不能因AuthExpiredError崩溃。
  • 多 Kiln 项目共享同一克隆(罕见的同一仓库二次导入场景):续期只会写入当前 manager 所属项目的配置;共享该克隆的其他项目可能在下次续期/重启前保留过期令牌。这是文档记录在案的已知限制,常规"一克隆一项目"场景完全正确。

五、数据契约与 API 变更

5.1GitSyncProjectConfig新增三个字段

oauth_refresh_token: str | None oauth_token_expires_at: float | None # epoch seconds, wall-clock oauth_refresh_token_expires_at: float | None

配套两个要求:

  • libs/core/kiln_ai/utils/config.py 中git_sync_projects属性的sensitive_keys需从["pat_token", "oauth_token"]扩展为["pat_token", "oauth_token", "oauth_refresh_token"],确保日志与导出中的脱敏(redaction)覆盖新敏感字段——当前仓库的脱敏机制见同一文件第 362-364 行的sensitive_keys遍历逻辑。
  • get_git_sync_config()必须通过.get(...)将三个新字段默认化为None(旧配置缺少这些键);save_git_sync_config()已是整字典写入,仅需扩展 TypedDict。

此外,设计引入一个同步读-改-写的聚焦辅助函数update_oauth_tokens(project_path, result):只更新单个项目的 OAuth 令牌字段,且读取与写入之间不允许await,防止协程交错产生部分更新。

5.2OAuthTokenResult数据类(oauth.py 新增)

@dataclass class OAuthTokenResult: access_token: str refresh_token: str | None = None access_token_expires_at: float | None = None # epoch seconds (wall-clock) refresh_token_expires_at: float | None = None

在交换/续期时刻计算:GitHub 返回expires_in时expires_at = time.time() + expires_in,否则为None。

5.3 API 字段(全部为 additive、可选、向后兼容)

  • OAuthStatusResponse:新增oauth_refresh_token、oauth_token_expires_at、oauth_refresh_token_expires_at。
  • SaveConfigRequest与UpdateConfigRequest:接受同样的三个字段并持久化。
  • GitSyncConfigResponse:不暴露任何原始令牌(维持现状),可选新增布尔has_oauth_refresh_token用于诊断。

5.4 错误 → HTTP 映射(middlewareERROR_MAP)

异常状态码Detail
AuthExpiredError(新增)401"GitHub authorization expired. Reconnect your GitHub account in the project's Git Sync settings."
RemoteUnreachableError503"Cannot sync with remote. Check your connection."(不变)

值得注意的仓库现状:当前 errors.py 已存在GitAuthError("Git authentication failed or expired (bad/expired token, 401/403)"),且 ERROR_MAP 已将其映射为 401 + "Re-import the project to reconnect with fresh credentials."。可以说,仓库已具备"认证错误 → 401"的分类雏形,而本规格在此基础上新增面向 OAuth 续期场景的AuthExpiredError与专属 reconnect 文案。

六、配置与默认值

参数值说明
REFRESH_SKEW_SECONDS300到期前 5 分钟内即触发主动续期,提前量容忍时钟偏差与调度延迟
GitHub token 端点https://github.com/login/oauth/access_token续期与 code 交换共用同一端点,grant_type=refresh_token
httpx 续期超时30s与现有 code 交换调用一致(当前 exchange_code_for_token 即使用timeout=30.0)

七、并发与正确性约束

  • 单进程 / 单事件循环:桌面端是单个 FastAPI 进程,因此按仓库粒度使用asyncio.Lock即可满足续期串行化——无需跨进程分布式锁。锁内要重新读取配置做双重检查(double-checked expiry):等待锁的协程获锁后不应再重复续期,保证 GitHub 的单次使用 refresh token 恰好被消费一次。
  • 配置写入必须同步:update_oauth_tokens的读-改-写全程无await,防止协程在事件循环上交错执行部分更新。
  • 时钟语义:过期判断使用墙钟time.time()(令牌有效性本质是墙钟时间);time.monotonic()仅用于内存中 OAuth 流程的 TTL(当前 OAuthFlowState.created_at 与OAUTH_TIMEOUT_SECONDS = 300正是如此)。

八、安全边界

  • refresh token 会经localhost 前端(sessionStorage)传输,并以明文形式存储在配置 YAML 中——与现有oauth_token的存储方式完全一致。对本地桌面应用而言可接受,且由sensitive_keys脱敏兜底。
  • 续期成功后才持久化;失败不写任何状态,杜绝脏数据。

九、仓库现状对照:哪些已具备,哪些待实现

以当前仓库为准(规格文档status: complete,但对应代码改动尚未落地),逐项对照:

规格目标当前仓库现状证据位置
捕获 refresh_token / 过期时间未实现:exchange_code_for_token仅返回 access_token,其余字段被丢弃oauth.py
配置存储 refresh_token / 过期时间未实现:GitSyncProjectConfig仅有oauth_tokenconfig.py
sensitive_keys覆盖 refresh_token未实现:目前仅["pat_token", "oauth_token"]libs/core/kiln_ai/utils/config.py
续期引擎_ensure_valid_oauth_token未实现:manager 无 refresh 逻辑、无_refresh_lockgit_sync_manager.py
认证 vs 网络错误识别已具备雏形:_AUTH_ERROR_MARKERS与_is_auth_error()已存在(匹配 authentication/authoriz/credential/401/403 等)git_sync_manager.py
认证错误 → 401已具备雏形:GitAuthError → 401已映射,但文案面向"重新导入项目"而非"OAuth 重连"middleware.py
前端携带新字段未实现:OAuthFlowCallbacks.onSuccess(token: string)、stored_token均只持有单值令牌oauth_flow.ts、oauth_with_install.ts

从源码结构可以推断,_is_auth_error与GitAuthError → 401映射是本次改造的既有土壤:规格中的被动续期判定可直接复用该函数,而AuthExpiredError可视为GitAuthError在 OAuth 续期语义下的细化。前端一侧,规格要求将onSuccess回调签名从(token: string)演进为载荷对象{ oauth_token, oauth_refresh_token, oauth_token_expires_at, oauth_refresh_token_expires_at },且前端时间戳为不透明透传值——由服务端计算、前端只负责从 OAuth 状态搬运到 save/update config,不做任何解析或续期。

十、测试与验证策略

10.1 后端(pytest,位于 app/desktop/git_sync/test_*.py + integration_tests)

  • oauth:exchange_code_for_token解析全部字段并计算expires_at(mock httpx);缺失expires_in→None;refresh_access_token成功 + 轮换 + 失败抛OAuthError;通过 patchtime.time使过期判断可确定性测试。
  • config:新字段往返;旧配置缺键默认None;update_oauth_tokens只更新令牌字段、对未知 project_path 为 no-op。
  • manager:_ensure_valid_oauth_token覆盖——有效(不发网络请求)、skew 内(不发请求)、过期+有 refresh(发起续期并持久化、更新_oauth_token)、过期+无 refresh(抛AuthExpiredError)、非 OAuth 模式(no-op)、force=True;并发下两个等待者只产生一次网络续期;_is_auth_error分类;fetch()/commit_and_push()被动续期 + 单次重试;ensure_fresh*透传AuthExpiredError。
  • middleware:AuthExpiredError → 401 + 文案;RemoteUnreachableError仍 503。
  • api:回调把 refresh 字段存入 flow;status 返回它们;save/update 持久化;auth_mode 切换清空 refresh 字段。
  • background_sync:AuthExpiredError被记录且轮询循环存活。

10.2 前端(vitest)

  • oauth_flow.test.ts:onSuccess收到载荷对象;status mock 含新字段。
  • api.test.ts/ 向导 store 测试:新字段进入 save/update 请求体。

10.3 端到端验证

  1. uv run ./checks.sh --agent-mode(lint、format、ty、pytest、web lint/format/check/test/build、schema check)。
  2. Schema 再生成:运行app/web_ui/src/lib/generate_schema.sh,确认app/web_ui/src/lib/check_schema.sh通过。
  3. 手工验证(可选):OAuth 连接一个仓库后,把配置中oauth_token_expires_at改为近过去的值强制触发 B2,确认 fetch 触发续期(新令牌已持久化)且同步继续;再清空oauth_refresh_token,确认下一次操作返回401 + reconnect 文案(而非 503)。
  4. 前置条件核对:确认 GitHub App 已开启 "Expire user authorization tokens"(GitHub App 设置 → Optional features)——这是 GitHub 愿意签发 refresh token 的前提(对应 B6)。

十一、小结

GitHub OAuth Token Refresh 设计给出了一个完整的"预防为主、兜底为辅"的令牌生命周期方案:B1/B4 保证 refresh token 与过期时间被捕获并随轮换及时落库;B2 主动续期让令牌在到期前 5 分钟自动更新;B3 被动续期在认证被拒时强制续期并重试一次;B5 则从根上修正了"认证过期被误报为网络故障"的体验问题——不可续期时返回 401 + "重新连接 GitHub 账户",而真正的网络故障保持 503。叠加按仓库串行化的asyncio.Lock双重检查、同步读-改-写持久化与sensitive_keys脱敏,这套方案在本地单进程桌面应用的约束下做到了正确、安全、可测试。对使用github_oauth模式的 Kiln 用户而言,其效果是:8 小时的失效魔咒被打破,同步在约 6 个月的 refresh token 周期内无需任何用户操作。

  • 人工智能
  • AI 应用
  • AI 评测
  • RAG
  • 微调
  • 提示工程
  • 开发工具
  • 桌面应用

【免费下载链接】Kiln

Build, Evaluate, and Optimize AI Systems. Includes evals, RAG, agents, fine-tuning, synthetic data generation, dataset management, MCP, and more.

项目地址:https://gitcode.com/gh_mirrors/ki/Kiln
点击查看免费下载

相关推荐

上一篇:Windows 11终极指南:如何用WSA Toolbox轻松安装Android应用
下一篇:Windows 11安卓应用革命:WSA Toolbox终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进