飞书 lark-cli 如何存储 tenant_access_token 和 user_access_token

读 larksuite/cli 的认证代码时,我最关心的不是 OAuth 的完整流程,而是一个更具体的问题:tenant_access_token 和 user_access_token 到底存在哪里?为什么一个要存,一个不存?

结论

lark-cli 把两类 token 当成两种不同生命周期的凭证来处理:

  • tenant_access_token:不持久化存储。需要 bot 身份时,用 app_id / app_secret 临时换取,并在当前进程内缓存一次。
  • user_access_token:持久化存储。用户登录授权后,把 access token、refresh token、过期时间和 scope 存进 keychain;使用时自动判断是否刷新。
  • 环境变量模式:如果外部设置了 LARKSUITE_CLI_USER_ACCESS_TOKEN 或 LARKSUITE_CLI_TENANT_ACCESS_TOKEN,CLI 直接使用外部 token,不负责持久化和刷新。

tenant_access_token:默认不落盘

默认 token provider 里,TAT 的解析入口是 resolveTAT:

go 复制代码
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
    p.tatOnce.Do(func() {
        p.tatResult, p.tatErr = p.doResolveTAT(ctx)
    })
    return p.tatResult, p.tatErr
}

源码位置:internal/credential/default_provider.go。

这里的 sync.Once 只做进程内缓存。也就是说,同一次 CLI 运行里第一次拿到 TAT 后,后面复用;进程退出后缓存就没了。

真正换取 TAT 的代码在 FetchTAT:

go 复制代码
form.Set("grant_type", "client_credentials")
form.Set("client_id", appID)
form.Set("client_secret", appSecret)

POST {accounts}/oauth/v3/token

源码位置:internal/credential/tat_fetch.go。

也就是说,lark-cli 持久保存的是 app 配置和 app secret,而不是 TAT 本身。

优点

安全边界更清楚。TAT 是短期派生凭证,既然可以用 app secret 随时换,就没有必要再多存一份 bearer token。

实现简单。不需要维护 TAT 的过期时间、刷新逻辑、跨进程锁和清理策略。

错误更直接。如果 app secret 配错了,换 token 的地方会直接暴露配置错误,而不是因为读到了某个旧 token 让问题变得模糊。

缺点

每个新进程第一次调用 bot API 都要请求一次 token endpoint。CLI 是短进程工具,这个成本通常能接受;但如果是高频批量调用,会多一些网络开销。

离线不可用。即使本地曾经成功拿到过 TAT,下次没有网络也不能复用。

user_access_token:存进 keychain

UAT 不一样。它代表某个用户对某个 app 的授权,拿到它需要用户参与登录,所以 CLI 会持久化保存。

存储结构在 StoredUAToken:

go 复制代码
type StoredUAToken struct {
    UserOpenId       string `json:"userOpenId"`
    AppId            string `json:"appId"`
    AccessToken      string `json:"accessToken"`
    RefreshToken     string `json:"refreshToken"`
    ExpiresAt        int64  `json:"expiresAt"`
    RefreshExpiresAt int64  `json:"refreshExpiresAt"`
    Scope            string `json:"scope"`
    GrantedAt        int64  `json:"grantedAt"`
}

源码位置:internal/auth/token_store.go。

keychain 的 key 是:

go 复制代码
func accountKey(appId, userOpenId string) string {
    return fmt.Sprintf("%s:%s", appId, userOpenId)
}

所以 UAT 是按 (appId, userOpenId) 维度存的。配置文件里只记录当前 profile 关联的 userOpenId / userName,真正敏感的 token 数据放在 keychain 里。

读取时不会盲目返回 access token,而是先判断状态:

go 复制代码
func TokenStatus(token *StoredUAToken) string {
    now := time.Now().UnixMilli()
    if now < token.ExpiresAt-refreshAheadMs {
        return "valid"
    }
    if now < token.RefreshExpiresAt {
        return "needs_refresh"
    }
    return "expired"
}

源码位置:internal/auth/token_store.go。

refreshAheadMs 是 5 分钟。也就是说,access token 快过期时 CLI 会提前刷新,避免刚拿到 token 就在真实请求里过期。

优点

用户体验好。用户授权一次后,后续 user 身份命令可以直接运行,不需要每次重新扫码或打开授权链接。

能自动刷新。只要 refresh token 还有效,CLI 可以静默换新的 access token。

支持多用户/多应用隔离。appId:userOpenId 这个 key 让不同 app、不同用户的授权不会混在一起。

缺点

实现复杂。UAT 需要保存 refresh token、过期时间、scope,还要处理 refresh 失败、refresh token 过期、本地清理等情况。

并发刷新要小心。多个 CLI 进程可能同时发现 token 需要刷新,所以源码里用了进程内锁和跨进程文件锁:

go 复制代码
// process-level lock
refreshLocks.LoadOrStore(key, done)

// cross-process lock
fileLock := flock.New(lockFile)
fileLock.TryLockContext(ctx, 500*time.Millisecond)

源码位置:internal/auth/uat_client.go。

本地 keychain 也会成为依赖。如果 OS keychain 不可用,user token 的读取和写入就会失败。

为什么 TAT 不存,UAT 要存

核心原因是两者的获取成本不同。

TAT 是 app 级 token。只要有 app secret,机器自己就能换。存它的收益有限,反而增加一份需要保护和清理的敏感数据。

UAT 是用户级 token。它背后是用户授权,获取过程需要用户参与。为了不让每次命令都打断用户,CLI 必须保存授权结果,并用 refresh token 延长可用时间。

所以这套设计可以总结成一句话:

能由机器便宜再生的 token,不持久化;需要用户参与才能得到的 token,安全持久化并自动刷新。

环境变量模式是另一个边界

环境变量 provider 会读取:

text 复制代码
LARKSUITE_CLI_USER_ACCESS_TOKEN
LARKSUITE_CLI_TENANT_ACCESS_TOKEN

解析逻辑很直接:

go 复制代码
case TokenTypeUAT:
    envKey = envvars.CliUserAccessToken
case TokenTypeTAT:
    envKey = envvars.CliTenantAccessToken

源码位置:extension/credential/env/env.go。

这里 CLI 只消费 token,不负责存储和刷新。它适合 CI、sidecar 或 agent runtime:外部系统负责 token 生命周期,lark-cli 只负责把 token 用到请求里。

相关推荐
lzhdim8 小时前
AI的分类
人工智能·算法·机器学习·分类·数据挖掘
程序员海军10 小时前
用了两年多 GPT,我最近开始换模型了,还折腾了个求职避坑 Agent
前端·人工智能·后端
AIsoft_868910 小时前
人才评估中的关键信息如何沉淀?
人工智能·iphone
紧跟先前的步伐10 小时前
Go AI Agent 实战 06|JSON 结构化输出与 Go 后端校验
人工智能·ai·golang
vibecoding7712 小时前
Codex 分层用模型完整教程:让 GPT-6.1 Sol 拍板、GPT-6 Luna 跑侦察
人工智能·gpt
新知图书12 小时前
1.3 启动第一个机器人
人工智能·机器人
明志数科12 小时前
自主探索范式下机器人示教数据的工程定位:少量示范为何对质量更敏感
人工智能·机器学习·机器人
Maynor99613 小时前
WorkBuddy 一键接通 gpt-6.1-sol:MomoAI / TryAllAPI 把最新模型接到桌面
人工智能·gpt·ai·大模型·编程工具
火山引擎开发者社区13 小时前
问得出、搜得准、信得过:ContextSearch 重构销售检索体验
人工智能
Raymond168813 小时前
AI配音工具长文本合成架构技术解析:全局音色锚定与声学状态跟踪
人工智能·架构·实时互动·视觉检测·音视频·语音识别