mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4dfe1b7dc0
Add Extended-only direct, credential-proxy, and platform-proxy modes while keeping Standard behavior and external-credential product details isolated behind source-neutral runtime capabilities. Fail closed across helper execution, proxy routing, streams, managed file transfers, and unsupported event flows. Harden system configuration, edition updates, and release publication. Cover Standard compatibility and Extended integration with contract tests, repository-local three-mode E2E, native trust checks, and CI and release gates.
438 lines
13 KiB
Go
438 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
)
|
|
|
|
// DefaultAccountResolver is implemented by the default account provider.
|
|
type DefaultAccountResolver interface {
|
|
ResolveAccount(ctx context.Context) (*Account, error)
|
|
}
|
|
|
|
// DefaultTokenResolver is implemented by the default token provider.
|
|
type DefaultTokenResolver interface {
|
|
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
|
}
|
|
|
|
type userInfoEnrichmentSkipper interface {
|
|
SkipUserInfoEnrichment() bool
|
|
}
|
|
|
|
// ProviderCapabilities describes source-neutral credential behavior used by
|
|
// inspection and account resolution. It carries no provider configuration.
|
|
type ProviderCapabilities struct {
|
|
SkipUserInfoEnrichment bool
|
|
ProvidesOnDemandAuth bool
|
|
CanInspectScopes bool
|
|
}
|
|
|
|
type providerCapabilitiesSource interface {
|
|
CredentialCapabilities() ProviderCapabilities
|
|
}
|
|
|
|
// ProviderAccountMetadata carries source-neutral Profile preferences that are
|
|
// not part of the public credential extension account contract. Internal
|
|
// runtime providers can expose them without coupling core configuration to a
|
|
// concrete credential product.
|
|
type ProviderAccountMetadata struct {
|
|
Lang i18n.Lang
|
|
}
|
|
|
|
type providerAccountMetadataSource interface {
|
|
CredentialAccountMetadata() ProviderAccountMetadata
|
|
}
|
|
|
|
var (
|
|
getStoredToken = auth.GetStoredToken
|
|
getStoredTokenStatus = auth.TokenStatus
|
|
)
|
|
|
|
type credentialSource interface {
|
|
Name() string
|
|
TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error)
|
|
ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error)
|
|
}
|
|
|
|
type extensionTokenSource struct {
|
|
provider extcred.Provider
|
|
}
|
|
|
|
func (s extensionTokenSource) Name() string { return s.provider.Name() }
|
|
|
|
func (s extensionTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
|
tok, err := s.provider.ResolveToken(ctx, extcred.TokenSpec{
|
|
Type: extcred.TokenType(req.Type.String()),
|
|
AppID: req.AppID,
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if tok == nil {
|
|
return nil, false, nil
|
|
}
|
|
if tok.Value == "" {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
|
}
|
|
return &TokenResult{Token: tok.Value, Scopes: tok.Scopes}, true, nil
|
|
}
|
|
|
|
func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
|
hint := &IdentityHint{}
|
|
if acct == nil {
|
|
return hint, nil
|
|
}
|
|
hint.DefaultAs = acct.DefaultAs
|
|
// Extension sources verify user identity via enrichUserInfo, so a resolved
|
|
// UserOpenId is sufficient here; no keychain-backed token status lookup is needed.
|
|
if acct.UserOpenId != "" {
|
|
hint.AutoAs = core.AsUser
|
|
return hint, nil
|
|
}
|
|
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
|
switch {
|
|
case ids.UserOnly():
|
|
hint.AutoAs = core.AsUser
|
|
case ids.BotOnly():
|
|
hint.AutoAs = core.AsBot
|
|
}
|
|
return hint, nil
|
|
}
|
|
|
|
type defaultTokenSource struct {
|
|
resolver DefaultTokenResolver
|
|
}
|
|
|
|
func (s defaultTokenSource) Name() string { return "default" }
|
|
|
|
func (s defaultTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
|
if s.resolver == nil {
|
|
return nil, false, nil
|
|
}
|
|
result, err := s.resolver.ResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if result == nil {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "nil token result"}
|
|
}
|
|
if result.Token == "" {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
|
}
|
|
return result, true, nil
|
|
}
|
|
|
|
func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
|
hint := &IdentityHint{}
|
|
if acct == nil {
|
|
return hint, nil
|
|
}
|
|
hint.DefaultAs = acct.DefaultAs
|
|
if acct.UserOpenId == "" {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
stored := getStoredToken(acct.AppID, acct.UserOpenId)
|
|
if stored == nil {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
if getStoredTokenStatus(stored) == "expired" {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
hint.AutoAs = core.AsUser
|
|
return hint, nil
|
|
}
|
|
|
|
// CredentialProvider is the unified entry point for all credential resolution.
|
|
type CredentialProvider struct {
|
|
providers []extcred.Provider
|
|
defaultAcct DefaultAccountResolver
|
|
defaultToken DefaultTokenResolver
|
|
httpClient func() (*http.Client, error)
|
|
warnOut io.Writer
|
|
|
|
accountOnce sync.Once
|
|
account *Account
|
|
accountErr error
|
|
selectedSource credentialSource
|
|
selectedProvider extcred.Provider
|
|
selectedCaps ProviderCapabilities
|
|
|
|
hintOnce sync.Once
|
|
hint *IdentityHint
|
|
hintErr error
|
|
}
|
|
|
|
// NewCredentialProvider creates a CredentialProvider.
|
|
func NewCredentialProvider(providers []extcred.Provider, defaultAcct DefaultAccountResolver, defaultToken DefaultTokenResolver, httpClient func() (*http.Client, error)) *CredentialProvider {
|
|
return &CredentialProvider{
|
|
providers: providers,
|
|
defaultAcct: defaultAcct,
|
|
defaultToken: defaultToken,
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
|
p.warnOut = warnOut
|
|
return p
|
|
}
|
|
|
|
// ResolveAccount resolves app credentials. Result is cached after first call.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
|
// Subsequent calls return the cached result regardless of their context.
|
|
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
|
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
|
p.accountOnce.Do(func() {
|
|
p.account, p.accountErr = p.doResolveAccount(ctx)
|
|
})
|
|
return p.account, p.accountErr
|
|
}
|
|
|
|
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
|
for _, prov := range p.providers {
|
|
acct, err := prov.ResolveAccount(ctx)
|
|
if err != nil {
|
|
// A provider error stops the chain, so remember that source as the
|
|
// immutable selection even though account resolution failed.
|
|
p.selectedSource = extensionTokenSource{provider: prov}
|
|
p.selectedProvider = prov
|
|
p.selectedCaps = credentialProviderCapabilities(prov)
|
|
return nil, err
|
|
}
|
|
if acct != nil {
|
|
internal := convertAccount(acct)
|
|
if source, ok := prov.(providerAccountMetadataSource); ok {
|
|
metadata := source.CredentialAccountMetadata()
|
|
internal.Lang = metadata.Lang
|
|
}
|
|
source := extensionTokenSource{provider: prov}
|
|
capabilities := credentialProviderCapabilities(prov)
|
|
skipEnrichment := capabilities.SkipUserInfoEnrichment
|
|
if skipper, ok := prov.(userInfoEnrichmentSkipper); ok {
|
|
skipEnrichment = skipper.SkipUserInfoEnrichment()
|
|
}
|
|
if !skipEnrichment {
|
|
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
|
if p.warnOut != nil {
|
|
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
|
}
|
|
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
|
// (used for strict mode) is already set by the provider.
|
|
// Clear unverified user identity for safety.
|
|
internal.UserOpenId = ""
|
|
internal.UserName = ""
|
|
}
|
|
}
|
|
p.selectedSource = source
|
|
p.selectedProvider = prov
|
|
p.selectedCaps = capabilities
|
|
return internal, nil
|
|
}
|
|
}
|
|
if p.defaultAcct != nil {
|
|
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return acct, nil
|
|
}
|
|
return nil, core.NotConfiguredError()
|
|
}
|
|
|
|
func credentialProviderCapabilities(provider extcred.Provider) ProviderCapabilities {
|
|
if source, ok := provider.(providerCapabilitiesSource); ok {
|
|
return source.CredentialCapabilities()
|
|
}
|
|
return ProviderCapabilities{CanInspectScopes: true}
|
|
}
|
|
|
|
// enrichUserInfo resolves user identity when extension provides a UAT.
|
|
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
|
// If no UAT from extension, falls back to provider-supplied OpenID.
|
|
func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, source credentialSource) error {
|
|
if p.httpClient == nil || source == nil {
|
|
return nil
|
|
}
|
|
tok, found, err := source.TryResolveToken(ctx, TokenSpec{Type: TokenTypeUAT, AppID: acct.AppID})
|
|
if err != nil {
|
|
var blockErr *extcred.BlockError
|
|
if errors.As(err, &blockErr) {
|
|
return nil // provider explicitly blocks UAT; skip enrichment
|
|
}
|
|
return fmt.Errorf("failed to resolve UAT for user identity verification: %w", err)
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
// Have UAT — must verify and resolve identity
|
|
hc, err := p.httpClient()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get HTTP client for user_info: %w", err)
|
|
}
|
|
info, err := fetchUserInfo(ctx, hc, acct.Brand, tok.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify user identity: %w", err)
|
|
}
|
|
acct.UserOpenId = info.OpenID
|
|
acct.UserName = info.Name
|
|
return nil
|
|
}
|
|
|
|
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
|
if p.selectedSource != nil {
|
|
return p.selectedSource, nil
|
|
}
|
|
if p.defaultAcct == nil {
|
|
return nil, nil
|
|
}
|
|
if _, err := p.ResolveAccount(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.selectedSource == nil {
|
|
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
|
}
|
|
return p.selectedSource, nil
|
|
}
|
|
|
|
func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, &TokenUnavailableError{Source: source.Name(), Type: req.Type}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ResolveIdentityHint resolves default/auto identity guidance from the selected source.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
|
// This matches ResolveAccount and keeps identity decisions stable within one CLI invocation.
|
|
func (p *CredentialProvider) ResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
|
p.hintOnce.Do(func() {
|
|
p.hint, p.hintErr = p.doResolveIdentityHint(ctx)
|
|
})
|
|
return p.hint, p.hintErr
|
|
}
|
|
|
|
func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
|
acct, err := p.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if acct == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
source, err := p.selectedCredentialSource(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
hint, err := source.ResolveIdentityHint(ctx, acct)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hint == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
return hint, nil
|
|
}
|
|
|
|
// ResolveToken resolves an access token.
|
|
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
|
source, err := p.selectedCredentialSource(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source != nil {
|
|
return resolveTokenFromSource(ctx, source, req)
|
|
}
|
|
|
|
for _, prov := range p.providers {
|
|
source := extensionTokenSource{provider: prov}
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
return result, nil
|
|
}
|
|
}
|
|
source = defaultTokenSource{resolver: p.defaultToken}
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
return result, nil
|
|
}
|
|
return nil, &TokenUnavailableError{Type: req.Type}
|
|
}
|
|
|
|
// ActiveExtensionProviderName reports whether an extension provider is managing
|
|
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
|
// and returns the name of the first engaged provider.
|
|
//
|
|
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
|
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
|
// external). Any other error is propagated to the caller.
|
|
//
|
|
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
|
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
|
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
|
|
for _, prov := range p.providers {
|
|
acct, err := prov.ResolveAccount(ctx)
|
|
if err != nil {
|
|
var blockErr *extcred.BlockError
|
|
if errors.As(err, &blockErr) {
|
|
name := blockErr.Provider
|
|
if name == "" {
|
|
name = prov.Name()
|
|
}
|
|
if name == "" {
|
|
name = "external"
|
|
}
|
|
return name, nil
|
|
}
|
|
return "", err
|
|
}
|
|
if acct != nil {
|
|
if name := prov.Name(); name != "" {
|
|
return name, nil
|
|
}
|
|
return "external", nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func convertAccount(ext *extcred.Account) *Account {
|
|
return &Account{
|
|
AppID: ext.AppID,
|
|
AppSecret: ext.AppSecret,
|
|
Brand: core.LarkBrand(ext.Brand),
|
|
DefaultAs: core.Identity(ext.DefaultAs),
|
|
ProfileName: ext.ProfileName,
|
|
UserOpenId: ext.OpenID,
|
|
SupportedIdentities: uint8(ext.SupportedIdentities),
|
|
}
|
|
}
|