mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
7a6a4dbdf5
* fix: preserve credential and config read failures Report corrupt or inaccessible stored credentials as typed errors instead of treating them as missing. Preserve config load failures across auth diagnostics and keep trusted request metadata available when host signal collection is unavailable. Fixes #1925 * fix(auth): attach re-authorization recovery to corrupt stored token errors A stored token that fails to decode or fails semantic validation is now reported as internal/storage but carried no recovery hint, so `api --as user`, `auth check`, `auth status`, and `doctor` all ended in a dead end. A new login overwrites the damaged entry, so attach the canonical user-authorization recovery on the two read-side corruption branches. The write-side validator stays hint-free: a rejected write leaves nothing on disk to re-authorize. `auth status` keeps the storage failure in `note` and appends the recovery instead of replacing it. Tests pin the hint on GetStoredToken, GetValidAccessToken, identitydiag (including reduced-distribution projection when auth login is concealed), auth check, and auth status, and pin that SetStoredToken replaces a corrupt entry. ---------
396 lines
12 KiB
Go
396 lines
12 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
|
|
CredentialSource() 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) CredentialSource() string {
|
|
switch s.Name() {
|
|
case "env", "sidecar":
|
|
return s.Name()
|
|
default:
|
|
return "extension"
|
|
}
|
|
}
|
|
|
|
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, Source: core.CredentialSource(s.CredentialSource())}, 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) CredentialSource() string { return "local" }
|
|
|
|
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"}
|
|
}
|
|
resultCopy := *result
|
|
resultCopy.Source = core.CredentialSource(s.CredentialSource())
|
|
return &resultCopy, 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)
|
|
}
|
|
requestCtx := core.WithCredentialSource(ctx, tok.Source)
|
|
info, err := fetchUserInfo(requestCtx, 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 (p *CredentialProvider) 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 p.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),
|
|
}
|
|
}
|