mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
208 lines
6.4 KiB
Go
208 lines
6.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
)
|
|
|
|
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
|
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
|
// with two distinct codes:
|
|
//
|
|
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
|
// - 10014: invalid app_secret ("app secret invalid")
|
|
//
|
|
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
|
// the configured credentials cannot mint a tenant access token. 10014 is
|
|
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
|
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
|
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
|
// the override stays local to this TAT call site instead of leaking into the
|
|
// shared codemeta table.
|
|
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
|
if code == 10003 {
|
|
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
|
WithCode(code).
|
|
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
|
}
|
|
return errclass.BuildAPIError(map[string]any{
|
|
"code": code,
|
|
"msg": msg,
|
|
}, errclass.ClassifyContext{
|
|
Brand: brand,
|
|
AppID: appID,
|
|
})
|
|
}
|
|
|
|
// DefaultAccountProvider resolves account from config.json via keychain.
|
|
type DefaultAccountProvider struct {
|
|
keychain func() keychain.KeychainAccess
|
|
profile string
|
|
}
|
|
|
|
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
|
if kc == nil {
|
|
kc = keychain.Default
|
|
}
|
|
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
|
}
|
|
|
|
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
|
// Load config once — used for both credentials and strict mode.
|
|
multi, err := core.LoadMultiAppConfig()
|
|
if err != nil {
|
|
return nil, core.NotConfiguredError()
|
|
}
|
|
|
|
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile)
|
|
return AccountFromCliConfig(cfg), nil
|
|
}
|
|
|
|
// strictModeToIdentitySupport maps the config-level strict mode to
|
|
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
|
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
|
app := multi.CurrentAppConfig(profileOverride)
|
|
var mode core.StrictMode
|
|
if app != nil && app.StrictMode != nil {
|
|
mode = *app.StrictMode
|
|
} else {
|
|
mode = multi.StrictMode
|
|
}
|
|
switch mode {
|
|
case core.StrictModeBot:
|
|
return uint8(extcred.SupportsBot)
|
|
case core.StrictModeUser:
|
|
return uint8(extcred.SupportsUser)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls.
|
|
// No SDK/LarkClient dependency — eliminates circular dependency with Factory.
|
|
type DefaultTokenProvider struct {
|
|
defaultAcct *DefaultAccountProvider
|
|
httpClient func() (*http.Client, error)
|
|
errOut io.Writer
|
|
|
|
tatOnce sync.Once
|
|
tatResult *TokenResult
|
|
tatErr error
|
|
}
|
|
|
|
func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient func() (*http.Client, error), errOut io.Writer) *DefaultTokenProvider {
|
|
return &DefaultTokenProvider{defaultAcct: defaultAcct, httpClient: httpClient, errOut: errOut}
|
|
}
|
|
|
|
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
|
switch req.Type {
|
|
case TokenTypeUAT:
|
|
return p.resolveUAT(ctx)
|
|
case TokenTypeTAT:
|
|
return p.resolveTAT(ctx)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
|
}
|
|
}
|
|
|
|
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
|
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
|
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient, err := p.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(acct.ToCliConfig(), p.errOut))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stored := auth.GetStoredToken(acct.AppID, acct.UserOpenId)
|
|
scopes := ""
|
|
if stored != nil {
|
|
scopes = stored.Scope
|
|
}
|
|
return &TokenResult{Token: token, Scopes: scopes}, nil
|
|
}
|
|
|
|
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used.
|
|
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
|
|
}
|
|
|
|
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient, err := p.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ep := core.ResolveEndpoints(acct.Brand)
|
|
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"app_id": acct.AppID,
|
|
"app_secret": acct.AppSecret,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
TenantAccessToken string `json:"tenant_access_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
|
}
|
|
if result.Code != 0 {
|
|
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
|
}
|
|
return &TokenResult{Token: result.TenantAccessToken}, nil
|
|
}
|