bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
382 lines
11 KiB
Go
382 lines
11 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
if acct != nil {
|
|
internal := convertAccount(acct)
|
|
source := extensionTokenSource{provider: prov}
|
|
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
|
|
return internal, nil
|
|
}
|
|
}
|
|
if p.defaultAcct != nil {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
|
return acct, nil
|
|
}
|
|
return nil, core.NotConfiguredError()
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|