mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
184 lines
6.0 KiB
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
|
|
}
|