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

larksuite/cli 的认证代码时,我最关心的不是 OAuth 的完整流程,而是一个更具体的问题:tenant_access_tokenuser_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_TOKENLARKSUITE_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 用到请求里。

相关推荐
Ralph_Salar1 小时前
从0到1搭建AI智能支付风控助手Stage3-Function Calling — 让AI能动起来
人工智能
Ralph_Salar1 小时前
从0到1搭建AI智能支付风控助手Stage4-Agent编排 — 让AI自己思考、决策、行动
人工智能
smallyoung1 小时前
Spring AI 2.0 VectorStore实战:从原理到RAG落地
人工智能·后端
火山引擎开发者社区2 小时前
被 Vibe Coding 用户频点名的火山 Supabase 到底是个啥?一图来看懂
人工智能
火山引擎开发者社区2 小时前
动手做 AI 实验赢好礼!产品 + 大模型免费额度限时供应!
人工智能
字节跳动视频云技术团队2 小时前
从 VCloud 到 Agentic VCloud:Agent 时代的范式重构
人工智能·音视频开发
AKAMAI3 小时前
每百万 Token 成本砍六成,出海 AI 团队开始重算推理这笔账
人工智能·云计算
止语Lab4 小时前
sync.Pool 的真正分界线不是对象大小——一次 benchmark 翻车记录
go
用户938515635074 小时前
从 Prompt 到 Harness:AI 工程化的三年跃迁与实战解码
javascript·人工智能