Files
larksuite-cli/internal/credential/default_provider.go

184 lines
6.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"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 deterministic (non-transient) failure from the
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
// reports failures using the OAuth 2.0 model — an `error` string plus an
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
//
// invalid_client / unauthorized_client mean the configured app_id/app_secret
// cannot mint a token; from the user's perspective that is the same actionable
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
// Every other deterministic error falls through to BuildAPIError, which still
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
// swallowing it. Transient/server-side failures (5xx / server_error) are
// filtered out by FetchTAT before this is called, so they stay untyped.
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
msg := errDesc
if msg == "" {
msg = oauthErr
}
switch oauthErr {
case "invalid_client", "unauthorized_client":
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
}
if err := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": msg,
}, errclass.ClassifyContext{
Brand: brand,
AppID: appID,
}); err != nil {
return err
}
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
// function is only reached once FetchTAT has ruled out success — a non-credential
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
// deterministic rejection. Back it with a typed APIError so callers never receive
// the ("", nil) "empty token, no error" pair.
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
}
// 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. The result is cached after the first
// call via 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
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
}