读 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 用到请求里。