Files
liangshuo-1 ce2abff8ae fix(config): propagate Lang across credential boundary; respect CurrentApp in priorLang (#1157)
Two issues caught in review of #1132 that the existing tests missed because
they constructed RuntimeContext/CliConfig directly, bypassing the credential
edge where the bug lives.

P1 — Lang dropped at credential boundary
  credential.Account had no Lang field, so AccountFromCliConfig and
  ToCliConfig silently dropped cfg.Lang. The production Factory builds
  CliConfig via acct.ToCliConfig() (factory_default.go Phase 3), which
  meant RuntimeContext.Lang() always returned "" in production and
  shortcuts/mail/mail_signature.go always fell back to zh_cn — defeating
  the whole point of persisting --lang.

  Fix: add Lang i18n.Lang to Account and copy it in both directions.

  Regression test: TestFullChain_LangSurvivesProductionPath walks the
  real path (SaveMultiAppConfig -> DefaultAccountProvider.ResolveAccount
  -> ToCliConfig) and asserts Lang survives, so any future field added
  to CliConfig forces the same audit.

P2 — priorLang ignored CurrentApp in multi-profile workspaces
  priorLang scanned all Apps and returned the first non-empty Lang. If a
  user had multiple profiles and the active one disagreed with Apps[0],
  a re-bind without --lang would silently inherit the wrong profile's
  preference.

  Fix: read multi.CurrentAppConfig("").Lang instead.

  Regression tests cover CurrentApp wins over Apps[0], single-app
  fallback, and malformed bytes.

Change-Id: If7a276605f84f398cec329c2c942b471b4c32749
2026-05-28 20:53:15 +08:00

180 lines
5.3 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"fmt"
"strings"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
)
// Account is the credential-layer view of the active runtime account.
// It intentionally mirrors only the resolved fields needed by runtime auth
// and identity selection, without exposing core.CliConfig as a dependency.
type Account struct {
ProfileName string
AppID string
AppSecret string
Brand core.LarkBrand
DefaultAs core.Identity
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8
}
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
// HasRealAppSecret reports whether secret is an actual app secret rather than
// an empty/token-only marker or the internal runtime placeholder.
func HasRealAppSecret(secret string) bool {
return secret != "" && secret != runtimePlaceholderAppSecret
}
// RuntimeAppSecret returns the SDK-compatible app secret used at runtime.
// Token-only sources intentionally have no real secret; this helper injects a
// private placeholder so downstream SDK validation can proceed while callers
// still distinguish real secrets with HasRealAppSecret.
func RuntimeAppSecret(secret string) string {
if HasRealAppSecret(secret) {
return secret
}
return runtimePlaceholderAppSecret
}
func normalizeAccountAppSecret(secret string) string {
if HasRealAppSecret(secret) {
return secret
}
return extcred.NoAppSecret
}
// AccountFromCliConfig copies the resolved config view into a credential.Account.
func AccountFromCliConfig(cfg *core.CliConfig) *Account {
if cfg == nil {
return nil
}
return &Account{
ProfileName: cfg.ProfileName,
AppID: cfg.AppID,
AppSecret: normalizeAccountAppSecret(cfg.AppSecret),
Brand: cfg.Brand,
DefaultAs: cfg.DefaultAs,
UserOpenId: cfg.UserOpenId,
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
}
}
// ToCliConfig copies the credential-layer account into the downstream config shape.
func (a *Account) ToCliConfig() *core.CliConfig {
if a == nil {
return nil
}
return &core.CliConfig{
ProfileName: a.ProfileName,
AppID: a.AppID,
AppSecret: normalizeAccountAppSecret(a.AppSecret),
Brand: a.Brand,
DefaultAs: a.DefaultAs,
UserOpenId: a.UserOpenId,
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
}
}
// AccountProvider resolves app credentials.
// Returns nil, nil to indicate "I don't handle this, try next provider".
type AccountProvider interface {
ResolveAccount(ctx context.Context) (*Account, error)
}
// TokenType distinguishes UAT from TAT.
// Uses string constants matching extension/credential.TokenType for zero-cost conversion.
type TokenType string
const (
TokenTypeUAT TokenType = "uat" // User Access Token
TokenTypeTAT TokenType = "tat" // Tenant Access Token
)
func (t TokenType) String() string { return string(t) }
// ParseTokenType converts a string to TokenType.
func ParseTokenType(s string) (TokenType, bool) {
switch strings.ToLower(s) {
case "uat":
return TokenTypeUAT, true
case "tat":
return TokenTypeTAT, true
default:
return "", false
}
}
// TokenSpec is the input to TokenProvider.ResolveToken.
type TokenSpec struct {
Type TokenType
AppID string // identifies which app (multi-account); not sensitive
}
// TokenResult is the output of TokenProvider.ResolveToken.
type TokenResult struct {
Token string
Scopes string // optional, space-separated; empty = skip scope pre-check
}
// IdentityHint is credential-layer guidance for resolving the effective identity.
type IdentityHint struct {
DefaultAs core.Identity
AutoAs core.Identity
}
// TokenUnavailableError reports that no usable token was available.
type TokenUnavailableError struct {
Source string
Type TokenType
}
func (e *TokenUnavailableError) Error() string {
if e.Source != "" {
return fmt.Sprintf("no %s available from credential source %q", e.Type, e.Source)
}
return fmt.Sprintf("no credential provider returned a token for %s", e.Type)
}
// MalformedTokenResultError reports that a source returned an invalid token payload.
type MalformedTokenResultError struct {
Source string
Type TokenType
Reason string
}
func (e *MalformedTokenResultError) Error() string {
return fmt.Sprintf("credential source %q returned malformed %s token: %s", e.Source, e.Type, e.Reason)
}
// TokenProvider resolves a runtime access token.
// Top-level resolvers should return a non-nil token or an error.
// Chain participants may use nil, nil internally to indicate "try next source".
type TokenProvider interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
}
// NewTokenSpec returns a TokenSpec with the token type automatically
// selected based on identity: TAT for bot, UAT for user.
func NewTokenSpec(identity core.Identity, appID string) TokenSpec {
t := TokenTypeUAT
if identity.IsBot() {
t = TokenTypeTAT
}
return TokenSpec{Type: t, AppID: appID}
}